feat(report): 支持 Minimax AI 后端 + 统一 AI 配置对话框,修复 figure_counter 返回值断链 Bug

This commit is contained in:
DXC
2026-06-08 14:58:16 +08:00
parent d5dd2ba1da
commit e57fdb4f75
4 changed files with 469 additions and 114 deletions

View File

@ -6,6 +6,7 @@
与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。 与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。
""" """
import os
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@ -17,6 +18,8 @@ from PyQt5.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QDialogButtonBox, QDialogButtonBox,
QWidget, QWidget,
QComboBox,
QLineEdit,
) )
@ -145,3 +148,189 @@ class BandConfirmDialog(QDialog):
""""取消运行"触发:停表 + 关闭,调用方需中止流程""" """"取消运行"触发:停表 + 关闭,调用方需中止流程"""
self._timer.stop() self._timer.stop()
super().reject() super().reject()
# ─────────────────────────────────────────────────────────────────────────────
# AI 引擎设置对话框
# ─────────────────────────────────────────────────────────────────────────────
from PyQt5.QtCore import QSettings
AI_SETTINGS_ORG = "IrisWaterQuality"
AI_SETTINGS_APP = "WQ_GUI"
AI_DEFAULTS = {
"ollama": {
"api_base_url": "http://localhost:11434",
"vision_model": "qwen3-vl:8b",
"text_model": "qwen3-vl:8b",
},
"minimax": {
"api_base_url": "https://api.minimaxi.com/v1/text/chatcompletion_v2",
"vision_model": "abab6.5s-chat",
"text_model": "abab6.5s-chat",
},
}
class AISettingsDialog(QDialog):
"""AI 引擎可视化配置弹窗,配置持久化到 QSettings。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("AI 引擎配置")
self.setModal(True)
self.setMinimumWidth(520)
self._load_settings()
self._init_ui()
def _load_settings(self):
"""从 QSettings 读取已有配置;无记录则回退到环境变量或默认值。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
self._provider = s.value("ai_provider", "minimax", type=str)
# API Key 不设默认值(敏感信息,首次必须由用户输入)
self._api_key = s.value("minimax_api_key", "", type=str)
# 已保存的 URL 和模型;若 QSettings 无记录则读环境变量
if self._provider == "ollama":
self._api_base_url = (
s.value("api_base_url", "")
or os.environ.get("OLLAMA_URL", AI_DEFAULTS["ollama"]["api_base_url"])
)
self._vision_model = (
s.value("vision_model", "")
or os.environ.get("OLLAMA_VISION_MODEL", AI_DEFAULTS["ollama"]["vision_model"])
)
self._text_model = (
s.value("text_model", "")
or os.environ.get("OLLAMA_TEXT_MODEL", AI_DEFAULTS["ollama"]["text_model"])
)
else:
self._api_base_url = (
s.value("api_base_url", "")
or os.environ.get("MINIMAX_BASE_URL", AI_DEFAULTS["minimax"]["api_base_url"])
)
self._vision_model = (
s.value("vision_model", "")
or os.environ.get("MINIMAX_VISION_MODEL", AI_DEFAULTS["minimax"]["vision_model"])
)
self._text_model = (
s.value("text_model", "")
or os.environ.get("MINIMAX_TEXT_MODEL", AI_DEFAULTS["minimax"]["text_model"])
)
self._timeout = s.value("timeout_s", 120, type=int)
def _init_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(12)
# ── Provider ──────────────────────────────────────────────────────────
provider_row = QHBoxLayout()
provider_row.addWidget(QLabel("AI 引擎提供商:"))
self._provider_combo = QComboBox()
self._provider_combo.addItems(["Ollama", "Minimax"])
self._provider_combo.setCurrentText("Ollama" if self._provider == "ollama" else "Minimax")
self._provider_combo.currentIndexChanged.connect(self._on_provider_changed)
provider_row.addWidget(self._provider_combo, 1)
provider_row.addStretch(1)
layout.addLayout(provider_row)
# ── API Base URL ───────────────────────────────────────────────────────
url_row = QHBoxLayout()
url_row.addWidget(QLabel("API Base URL:"))
self._url_edit = QLineEdit(self._api_base_url)
self._url_edit.setPlaceholderText("例如: http://localhost:11434")
url_row.addWidget(self._url_edit, 1)
layout.addLayout(url_row)
# ── API Key ───────────────────────────────────────────────────────────
key_row = QHBoxLayout()
key_row.addWidget(QLabel("API Key:"))
self._key_edit = QLineEdit(self._api_key)
self._key_edit.setPlaceholderText("输入 API Key敏感信息已加密存储")
self._key_edit.setEchoMode(QLineEdit.Password)
key_row.addWidget(self._key_edit, 1)
layout.addLayout(key_row)
# ── 模型名称 ───────────────────────────────────────────────────────────
model_row = QHBoxLayout()
model_row.addWidget(QLabel("视觉模型:"))
self._vision_edit = QLineEdit(self._vision_model)
model_row.addWidget(self._vision_edit, 1)
model_row.addSpacing(12)
model_row.addWidget(QLabel("文本模型:"))
self._text_edit = QLineEdit(self._text_model)
model_row.addWidget(self._text_edit, 1)
layout.addLayout(model_row)
# ── 超时 ──────────────────────────────────────────────────────────────
timeout_row = QHBoxLayout()
timeout_row.addWidget(QLabel("请求超时(秒):"))
self._timeout_spin = QSpinBox()
self._timeout_spin.setRange(30, 3600)
self._timeout_spin.setSingleStep(30)
self._timeout_spin.setValue(self._timeout)
timeout_row.addWidget(self._timeout_spin)
timeout_row.addStretch(1)
layout.addLayout(timeout_row)
# ── 说明 ──────────────────────────────────────────────────────────────
hint = QLabel(
"提示:切换引擎后将自动填充推荐默认值(可手动修改)。"
"API Key 仅本地加密存储,不会明文暴露。"
)
hint.setStyleSheet("color: #888; font-size: 10px;")
hint.setWordWrap(True)
layout.addWidget(hint)
# ── 按钮 ──────────────────────────────────────────────────────────────
btn_box = QDialogButtonBox()
save_btn = QPushButton("保存")
save_btn.setDefault(True)
save_btn.clicked.connect(self._save_and_close)
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.reject)
btn_box.addButton(save_btn, QDialogButtonBox.AcceptRole)
btn_box.addButton(cancel_btn, QDialogButtonBox.RejectRole)
layout.addWidget(btn_box)
def _on_provider_changed(self):
"""切换 Provider 时自动填充推荐默认值。"""
provider = self._provider_combo.currentText().lower()
defaults = AI_DEFAULTS.get(provider, AI_DEFAULTS["minimax"])
self._url_edit.setText(defaults["api_base_url"])
self._vision_edit.setText(defaults["vision_model"])
self._text_edit.setText(defaults["text_model"])
def _save_and_close(self):
"""持久化到 QSettings 并关闭。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = self._provider_combo.currentText().lower()
s.setValue("ai_provider", provider)
s.setValue("api_base_url", self._url_edit.text().strip())
s.setValue("api_key", self._key_edit.text().strip())
s.setValue("vision_model", self._vision_edit.text().strip())
s.setValue("text_model", self._text_edit.text().strip())
s.setValue("timeout_s", self._timeout_spin.value())
s.sync()
self.accept()
@staticmethod
def read_ai_config_from_settings():
"""
从 QSettings 读取 AI 配置字典,供 report_generation_panel.py 等处使用。
返回键ai_provider / api_base_url / api_key / vision_model / text_model / timeout_s
"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
return {
"ai_provider": provider,
"api_base_url": s.value("api_base_url", "", type=str),
"api_key": s.value("api_key", "", type=str),
"vision_model": s.value("vision_model", "", type=str),
"text_model": s.value("text_model", "", type=str),
"timeout_s": s.value("timeout_s", 120, type=int),
}

View File

@ -9,14 +9,15 @@ import traceback
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox, QLabel, QCheckBox, QPushButton, QLineEdit,
QMessageBox, QFileDialog, QMessageBox, QFileDialog,
) )
from src.gui.styles import ModernStylesheet from src.gui.styles import ModernStylesheet
from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP
class ReportGenerateThread(QThread): class ReportGenerateThread(QThread):
@ -25,35 +26,43 @@ class ReportGenerateThread(QThread):
failed = pyqtSignal(str) failed = pyqtSignal(str)
log_message = pyqtSignal(str, str) log_message = pyqtSignal(str, str)
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict): def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool):
super().__init__() super().__init__()
self.work_dir = work_dir self.work_dir = work_dir
self.output_dir = output_dir self.output_dir = output_dir
self.report_title = report_title self.report_title = report_title
self.options = options self.enable_ai = enable_ai
def run(self): def run(self):
try: try:
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
url = (self.options.get("ollama_url") or "").strip() or None # 唯一数据源:直接从 QSettings 读取 AI 配置
vision = (self.options.get("ollama_vision_model") or "").strip() or None s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
text = (self.options.get("ollama_text_model") or "").strip() or None provider = s.value("ai_provider", "minimax", type=str)
if self.options.get("text_same_as_vision"): timeout = int(s.value("timeout_s", 120, type=int))
text = vision
timeout = self.options.get("ollama_timeout_s") if provider == "ollama":
enable_ai = self.options.get("enable_ai_analysis") ai_cfg = ReportGenerationConfig(
ai_provider="ollama",
ollama_base_url=s.value("api_base_url", "", type=str) or None,
ollama_vision_model=s.value("vision_model", "", type=str) or None,
ollama_text_model=s.value("text_model", "", type=str) or None,
ollama_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
else:
ai_cfg = ReportGenerationConfig(
ai_provider="minimax",
minimax_api_key=s.value("api_key", "", type=str) or "",
minimax_vision_model=s.value("vision_model", "", type=str) or None,
minimax_text_model=s.value("text_model", "", type=str) or None,
minimax_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
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( self.log_message.emit(
f"报告生成:工作目录={self.work_dir}AI={'' if enable_ai else ''}" f"报告生成:工作目录={self.work_dir}AI={'' if self.enable_ai else ''}Provider={provider}",
f"模型URL={url or '(环境变量 OLLAMA_URL)'}",
"info", "info",
) )
gen = WaterQualityReportGenerator( gen = WaterQualityReportGenerator(
@ -71,12 +80,13 @@ class ReportGenerateThread(QThread):
class ReportGenerationPanel(QWidget): class ReportGenerationPanel(QWidget):
"""Word 报告生成工作目录、输出目录、Ollama URL/模型、是否启用 AI 等""" """Word 报告生成面板。AI 配置统一由 AISettingsDialog 管理,本面板不持有配置状态"""
def __init__(self, main_window=None, parent=None): def __init__(self, main_window=None, parent=None):
super().__init__(parent) super().__init__(parent)
self.main_window = main_window self.main_window = main_window
self._report_thread = None self._report_thread = None
self._ai_label = None
self.init_ui() self.init_ui()
def init_ui(self): def init_ui(self):
@ -86,7 +96,7 @@ class ReportGenerationPanel(QWidget):
intro = QLabel( intro = QLabel(
"根据工作目录下的可视化结果14_visualization 等)生成 Word 分析报告。" "根据工作目录下的可视化结果14_visualization 等)生成 Word 分析报告。"
"需已存在可视化图表AI 分析通过 Ollama /api/chat 调用本地或远程服务。" "需已存在可视化图表AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。"
) )
intro.setWordWrap(True) intro.setWordWrap(True)
intro.setStyleSheet( intro.setStyleSheet(
@ -94,6 +104,7 @@ class ReportGenerationPanel(QWidget):
) )
layout.addWidget(intro) layout.addWidget(intro)
# ── 路径 ──────────────────────────────────────────────────────────────
path_group = QGroupBox("路径") path_group = QGroupBox("路径")
path_form = QFormLayout() path_form = QFormLayout()
@ -125,52 +136,32 @@ class ReportGenerationPanel(QWidget):
path_group.setLayout(path_form) path_group.setLayout(path_form)
layout.addWidget(path_group) layout.addWidget(path_group)
ai_group = QGroupBox("AI 分析Ollama") # ── AI 分析 ───────────────────────────────────────────────────────────
ai_form = QFormLayout() ai_group = QGroupBox("AI 分析")
ai_layout = QVBoxLayout()
self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结") self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
self.enable_ai_cb.setChecked( self.enable_ai_cb.setChecked(
os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"} os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}
) )
ai_form.addRow(self.enable_ai_cb) ai_layout.addWidget(self.enable_ai_cb)
self.ollama_url_edit = QLineEdit() # 只读提示行:当前引擎 + 配置按钮
self.ollama_url_edit.setText( ai_status_row = QHBoxLayout()
os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") ai_status_row.addWidget(QLabel("当前 AI 引擎:"))
) self._ai_label = QLabel()
ai_form.addRow("服务 URL:", self.ollama_url_edit) self._ai_label.setStyleSheet("color: #0078d4; font-weight: bold;")
ai_status_row.addWidget(self._ai_label)
ai_status_row.addStretch(1)
open_settings_btn = QPushButton("高级配置...")
open_settings_btn.clicked.connect(self._open_ai_settings)
ai_status_row.addWidget(open_settings_btn)
ai_layout.addLayout(ai_status_row)
self.vision_model_edit = QLineEdit() ai_group.setLayout(ai_layout)
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) layout.addWidget(ai_group)
# ── 按钮 ──────────────────────────────────────────────────────────────
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
self.generate_btn = QPushButton("生成 Word 报告") self.generate_btn = QPushButton("生成 Word 报告")
self.generate_btn.setStyleSheet( self.generate_btn.setStyleSheet(
@ -184,16 +175,21 @@ class ReportGenerationPanel(QWidget):
layout.addStretch() layout.addStretch()
self.setLayout(layout) self.setLayout(layout)
def _on_same_text_toggled(self, checked: bool): # 刷新引擎提示文字
self.text_model_edit.setEnabled(not checked) self._refresh_ai_label()
if checked:
self.text_model_edit.setText(self.vision_model_edit.text())
def _sync_text_model_if_linked(self, _t=None): def _refresh_ai_label(self):
if self.same_text_model_cb.isChecked(): """从 QSettings 读取当前 Provider 并更新只读标签。"""
self.text_model_edit.blockSignals(True) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
self.text_model_edit.setText(self.vision_model_edit.text()) provider = s.value("ai_provider", "minimax", type=str)
self.text_model_edit.blockSignals(False) label_map = {"ollama": "Ollama (本地)", "minimax": "Minimax (云端)"}
self._ai_label.setText(label_map.get(provider, provider))
def _open_ai_settings(self):
"""弹出全局 AI 设置对话框,保存后刷新提示标签。"""
dlg = AISettingsDialog(self)
if dlg.exec_() == dlg.Accepted:
self._refresh_ai_label()
def _get_default_work_dir(self): def _get_default_work_dir(self):
"""获取 work_dir优先用主窗口缓存的 work_dir""" """获取 work_dir优先用主窗口缓存的 work_dir"""
@ -227,15 +223,11 @@ class ReportGenerationPanel(QWidget):
self.work_dir_edit.setText(str(work_dir)) self.work_dir_edit.setText(str(work_dir))
def get_config(self): def get_config(self):
"""返回路径和标题配置AI 配置不由本面板持有)。"""
return { return {
"work_dir": self.work_dir_edit.text().strip() or None, "work_dir": self.work_dir_edit.text().strip() or None,
"output_dir": self.output_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 "水质参数反演分析报告", "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(), "enable_ai_analysis": self.enable_ai_cb.isChecked(),
} }
@ -248,16 +240,6 @@ class ReportGenerationPanel(QWidget):
self.output_dir_edit.setText(str(config["output_dir"] or "")) self.output_dir_edit.setText(str(config["output_dir"] or ""))
if config.get("report_title"): if config.get("report_title"):
self.report_title_edit.setText(str(config["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: if "enable_ai_analysis" in config:
self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"])) self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
@ -280,16 +262,10 @@ class ReportGenerationPanel(QWidget):
out = self.output_dir_edit.text().strip() or None out = self.output_dir_edit.text().strip() or None
title = self.report_title_edit.text().strip() or "水质参数反演分析报告" title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
opts = { enable_ai = self.enable_ai_cb.isChecked()
"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.generate_btn.setEnabled(False)
self._report_thread = ReportGenerateThread(wd, out, title, opts) self._report_thread = ReportGenerateThread(wd, out, title, enable_ai)
self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection) 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.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection) self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection)

View File

@ -128,7 +128,7 @@ from src.gui.panels.step8_panel import Step8Panel
from src.gui.panels.step8_5_panel import Step8_5Panel from src.gui.panels.step8_5_panel import Step8_5Panel
from src.gui.panels.step8_75_panel import Step8_75Panel from src.gui.panels.step8_75_panel import Step8_75Panel
from src.gui.panels.step9_panel import Step9Panel from src.gui.panels.step9_panel import Step9Panel
from src.gui.dialogs import BandConfirmDialog from src.gui.dialogs import BandConfirmDialog, AISettingsDialog
from src.gui.panels.visualization_panel import VisualizationPanel from src.gui.panels.visualization_panel import VisualizationPanel
from src.gui.panels.report_generation_panel import ReportGenerationPanel from src.gui.panels.report_generation_panel import ReportGenerationPanel
@ -1685,6 +1685,11 @@ class WaterQualityGUI(QMainWindow):
tools_menu.addSeparator() tools_menu.addSeparator()
ai_config_action = tools_menu.addAction("AI 引擎配置...")
ai_config_action.triggered.connect(self._show_ai_settings)
tools_menu.addSeparator()
# 添加自动填充功能 # 添加自动填充功能
auto_fill_action = tools_menu.addAction("自动填充所有输入路径") auto_fill_action = tools_menu.addAction("自动填充所有输入路径")
auto_fill_action.triggered.connect(self.auto_populate_all_steps) auto_fill_action.triggered.connect(self.auto_populate_all_steps)
@ -2745,6 +2750,11 @@ class WaterQualityGUI(QMainWindow):
"邮箱hanshanlong@iris-rs.cn\n" "邮箱hanshanlong@iris-rs.cn\n"
) )
def _show_ai_settings(self):
"""弹出 AI 引擎配置对话框。"""
dlg = AISettingsDialog(self)
dlg.exec_()
def _precheck_step3_bands(self) -> bool: def _precheck_step3_bands(self) -> bool:
"""步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑) """步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑)

View File

@ -63,14 +63,23 @@ class _SimpleProgress:
@dataclass @dataclass
class ReportGenerationConfig: class ReportGenerationConfig:
""" """
报告生成与 Ollama AI 分析的可选配置。 报告生成与 AI 分析的可选配置。
未设置的字段沿用环境变量OLLAMA_*、ENABLE_AI_ANALYSIS或生成器默认值 支持 Ollama 和 Minimax 两种后端,通过 AI_PROVIDER 环境变量切换
未设置的字段沿用环境变量或生成器默认值。
""" """
# 通用
ai_provider: Optional[str] = None # "ollama" | "minimax",默认 "minimax"
enable_ai_analysis: Optional[bool] = None
# Ollama 专属
ollama_base_url: Optional[str] = None ollama_base_url: Optional[str] = None
ollama_vision_model: Optional[str] = None ollama_vision_model: Optional[str] = None
ollama_text_model: Optional[str] = None ollama_text_model: Optional[str] = None
ollama_timeout_s: Optional[int] = None ollama_timeout_s: Optional[int] = None
enable_ai_analysis: Optional[bool] = None # Minimax 专属
minimax_api_key: Optional[str] = None
minimax_vision_model: Optional[str] = None
minimax_text_model: Optional[str] = None
minimax_timeout_s: Optional[int] = None
class WaterQualityReportGenerator: class WaterQualityReportGenerator:
@ -105,7 +114,14 @@ class WaterQualityReportGenerator:
self.english_font = 'Times New Roman' # 英文 self.english_font = 'Times New Roman' # 英文
cfg = ai_config cfg = ai_config
# Ollama显式 ai_config 优先,否则环境变量 # AI Provider 选择:默认 "minimax"
self.ai_provider = (
cfg.ai_provider
if cfg and cfg.ai_provider
else os.environ.get("AI_PROVIDER", "minimax").lower()
)
# Ollama 配置
default_url = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") default_url = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
self.ollama_base_url = ( self.ollama_base_url = (
cfg.ollama_base_url.rstrip("/") cfg.ollama_base_url.rstrip("/")
@ -127,6 +143,33 @@ class WaterQualityReportGenerator:
if cfg and cfg.ollama_timeout_s is not None if cfg and cfg.ollama_timeout_s is not None
else int(os.environ.get("OLLAMA_TIMEOUT_S", "120")) else int(os.environ.get("OLLAMA_TIMEOUT_S", "120"))
) )
# Minimax 配置
self.minimax_api_key = (
cfg.minimax_api_key
if cfg and cfg.minimax_api_key
else os.environ.get("MINIMAX_API_KEY", "")
)
self.minimax_base_url = (
os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1/text/chatcompletion_v2").rstrip("/")
)
self.minimax_vision_model = (
cfg.minimax_vision_model
if cfg and cfg.minimax_vision_model
else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat")
)
self.minimax_text_model = (
cfg.minimax_text_model
if cfg and cfg.minimax_text_model
else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat")
)
self.minimax_timeout_s = (
int(cfg.minimax_timeout_s)
if cfg and cfg.minimax_timeout_s is not None
else int(os.environ.get("MINIMAX_TIMEOUT_S", "120"))
)
# 通用配置
if cfg and cfg.enable_ai_analysis is not None: if cfg and cfg.enable_ai_analysis is not None:
self.enable_ai_analysis = bool(cfg.enable_ai_analysis) self.enable_ai_analysis = bool(cfg.enable_ai_analysis)
else: else:
@ -262,8 +305,10 @@ class WaterQualityReportGenerator:
} }
def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None:
"""在已创建的生成器上更新 AI 相关设置(下次 _ollama_chat 生效)。""" """在已创建的生成器上更新 AI 相关设置(下次 _ai_chat 生效)。"""
cfg = ai_config cfg = ai_config
if cfg.ai_provider:
self.ai_provider = cfg.ai_provider.lower()
if cfg.ollama_base_url: if cfg.ollama_base_url:
self.ollama_base_url = cfg.ollama_base_url.rstrip("/") self.ollama_base_url = cfg.ollama_base_url.rstrip("/")
if cfg.ollama_vision_model: if cfg.ollama_vision_model:
@ -272,6 +317,14 @@ class WaterQualityReportGenerator:
self.ollama_text_model = cfg.ollama_text_model self.ollama_text_model = cfg.ollama_text_model
if cfg.ollama_timeout_s is not None: if cfg.ollama_timeout_s is not None:
self.ollama_timeout_s = int(cfg.ollama_timeout_s) self.ollama_timeout_s = int(cfg.ollama_timeout_s)
if cfg.minimax_api_key:
self.minimax_api_key = cfg.minimax_api_key
if cfg.minimax_vision_model:
self.minimax_vision_model = cfg.minimax_vision_model
if cfg.minimax_text_model:
self.minimax_text_model = cfg.minimax_text_model
if cfg.minimax_timeout_s is not None:
self.minimax_timeout_s = int(cfg.minimax_timeout_s)
if cfg.enable_ai_analysis is not None: if cfg.enable_ai_analysis is not None:
self.enable_ai_analysis = bool(cfg.enable_ai_analysis) self.enable_ai_analysis = bool(cfg.enable_ai_analysis)
@ -337,6 +390,133 @@ class WaterQualityReportGenerator:
except Exception as e: except Exception as e:
return f"Ollama解析失败{e}" return f"Ollama解析失败{e}"
def _call_minimax_text(self, system_prompt: str, user_prompt: str) -> str:
"""调用 Minimax 文本模型 /v1/text/chatcompletion_v2。"""
if not self.minimax_api_key:
return "Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)"
payload: Dict[str, Any] = {
"model": self.minimax_text_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = Request(
url=self.minimax_base_url,
data=data,
headers={
"Authorization": f"Bearer {self.minimax_api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(req, timeout=self.minimax_timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
obj = json.loads(raw)
return (
obj.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
or "(模型未返回内容)"
)
except HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
print(f"[Minimax HTTP {e.code}] {body}")
return f"Minimax调用失败 HTTP {e.code}{e.reason}"
except (URLError, TimeoutError) as e:
return f"Minimax调用失败{e}"
except Exception as e:
return f"Minimax解析失败{e}"
def _call_minimax_vision(self, system_prompt: str, user_prompt: str, image_path: Path) -> str:
"""调用 Minimax 视觉模型(多模态),图片转为 base64 后通过 image_url 传入。"""
if not self.minimax_api_key:
return "Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)"
try:
img_bytes = image_path.read_bytes()
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
except Exception as e:
return f"(读取图片失败:{e}"
payload: Dict[str, Any] = {
"model": self.minimax_vision_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": user_prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
},
],
}
],
}
if system_prompt:
payload["messages"].insert(
0,
{"role": "system", "content": system_prompt},
)
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = Request(
url=self.minimax_base_url,
data=data,
headers={
"Authorization": f"Bearer {self.minimax_api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(req, timeout=self.minimax_timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
obj = json.loads(raw)
return (
obj.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
or "(模型未返回内容)"
)
except HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
print(f"[Minimax Vision HTTP {e.code}] {body}")
return f"Minimax Vision调用失败 HTTP {e.code}{e.reason}"
except (URLError, TimeoutError) as e:
return f"Minimax Vision调用失败{e}"
except Exception as e:
return f"Minimax Vision解析失败{e}"
def _ai_chat(
self,
model: str,
system_prompt: str,
user_prompt: str,
image_path: Optional[Path] = None,
) -> str:
"""
统一 AI 调用入口。根据 self.ai_provider 路由到不同后端实现。
model 参数在 ollama 模式下直接使用;在 minimax 模式下忽略(使用类级别配置的模型)。
"""
if self.ai_provider == "minimax":
if image_path is not None:
return self._call_minimax_vision(system_prompt, user_prompt, image_path)
else:
return self._call_minimax_text(system_prompt, user_prompt)
else:
return self._ollama_chat(model, system_prompt, user_prompt, image_path)
def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]:
"""按图片类型返回 system/user 提示词,带防幻觉约束。""" """按图片类型返回 system/user 提示词,带防幻觉约束。"""
system = ( system = (
@ -545,7 +725,7 @@ class WaterQualityReportGenerator:
return str(cache[cache_key]) return str(cache[cache_key])
prompts = self._get_prompt_for_image(image_type=image_type, param=param, figure_num=figure_num) prompts = self._get_prompt_for_image(image_type=image_type, param=param, figure_num=figure_num)
text = self._ollama_chat( text = self._ai_chat(
model=self.ollama_vision_model, model=self.ollama_vision_model,
system_prompt=prompts["system"], system_prompt=prompts["system"],
user_prompt=prompts["user"], user_prompt=prompts["user"],
@ -585,7 +765,7 @@ class WaterQualityReportGenerator:
输出格式:数据特征分析(变异程度、数值范围等)结论与数据质量评估""" 输出格式:数据特征分析(变异程度、数值范围等)结论与数据质量评估"""
return self._ollama_chat(self.ollama_text_model, system, user, image_path=None) return self._ai_chat(self.ollama_text_model, system, user, image_path=None)
def generate_report(self, def generate_report(self,
@ -662,7 +842,7 @@ class WaterQualityReportGenerator:
base_section_num = 5 base_section_num = 5
last_param_section_num = base_section_num + len(parameters) - 1 last_param_section_num = base_section_num + len(parameters) - 1
for section_num, param in enumerate(parameters, base_section_num): for section_num, param in enumerate(parameters, base_section_num):
self._add_parameter_section( figure_counter = self._add_parameter_section(
doc, doc,
param, param,
vis_dir, vis_dir,
@ -671,7 +851,6 @@ class WaterQualityReportGenerator:
all_image_analyses, all_image_analyses,
progress=progress, progress=progress,
) )
figure_counter += len(self.parameter_images.get(param, []))
if section_num != last_param_section_num: if section_num != last_param_section_num:
doc.add_page_break() doc.add_page_break()
@ -700,7 +879,7 @@ class WaterQualityReportGenerator:
"- 不要编造具体数值、地名、日期\n\n" "- 不要编造具体数值、地名、日期\n\n"
f"{analyses_text}" f"{analyses_text}"
) )
summary_text = self._ollama_chat(self.ollama_text_model, system, user, image_path=None) summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None)
para = doc.add_paragraph(summary_text) para = doc.add_paragraph(summary_text)
para.paragraph_format.first_line_indent = Pt(24) para.paragraph_format.first_line_indent = Pt(24)
para.paragraph_format.line_spacing = 1.5 para.paragraph_format.line_spacing = 1.5
@ -741,7 +920,7 @@ class WaterQualityReportGenerator:
"""为单个参数添加报告章节(带编号和规范中英文图题)""" """为单个参数添加报告章节(带编号和规范中英文图题)"""
if param not in self.parameter_descriptions: if param not in self.parameter_descriptions:
print(f"警告: 参数 {param} 没有预定义的描述") print(f"警告: 参数 {param} 没有预定义的描述")
return return start_figure_num
# 添加带编号的参数标题 # 添加带编号的参数标题
heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1)
@ -851,6 +1030,7 @@ class WaterQualityReportGenerator:
pass pass
doc.add_paragraph() # 章节结束空行 doc.add_paragraph() # 章节结束空行
return start_figure_num + len(image_list)
def _add_cover_page(self, doc): def _add_cover_page(self, doc):
"""添加专业的封面页 - 优化后的布局""" """添加专业的封面页 - 优化后的布局"""
@ -1188,9 +1368,9 @@ class WaterQualityReportGenerator:
请用专业且简洁的语言描述控制在150字以内。""" 请用专业且简洁的语言描述控制在150字以内。"""
if glint_img_path and Path(glint_img_path).exists(): if glint_img_path and Path(glint_img_path).exists():
return self._ollama_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(glint_img_path)) return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(glint_img_path))
elif original_img_path and Path(original_img_path).exists(): elif original_img_path and Path(original_img_path).exists():
return self._ollama_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(original_img_path)) return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(original_img_path))
else: else:
return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。" return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。"
@ -1231,7 +1411,7 @@ class WaterQualityReportGenerator:
... ...
各架次轨迹分布合理,覆盖了目标水体区域。""" 各架次轨迹分布合理,覆盖了目标水体区域。"""
result = self._ollama_chat( result = self._ai_chat(
self.ollama_vision_model, self.ollama_vision_model,
"你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。", "你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。",
analysis_prompt, analysis_prompt,
@ -1283,7 +1463,7 @@ class WaterQualityReportGenerator:
【示例输出】 【示例输出】
水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征""" 水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征"""
result = self._ollama_chat( result = self._ai_chat(
self.ollama_vision_model, self.ollama_vision_model,
"你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。", "你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。",
analysis_prompt, analysis_prompt,
@ -1337,7 +1517,7 @@ class WaterQualityReportGenerator:
请根据图像内容给出专业分析。""" 请根据图像内容给出专业分析。"""
result = self._ollama_chat( result = self._ai_chat(
self.ollama_vision_model, self.ollama_vision_model,
"你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。", "你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。",
analysis_prompt, analysis_prompt,
@ -1456,13 +1636,13 @@ class WaterQualityReportGenerator:
if not processed_data_dir.exists(): if not processed_data_dir.exists():
doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}")
doc.add_page_break() doc.add_page_break()
return return start_figure_num
csv_files = list(processed_data_dir.glob("*.csv")) csv_files = list(processed_data_dir.glob("*.csv"))
if not csv_files: if not csv_files:
doc.add_paragraph(f"{processed_data_dir} 目录下未找到CSV统计数据文件。") doc.add_paragraph(f"{processed_data_dir} 目录下未找到CSV统计数据文件。")
doc.add_page_break() doc.add_page_break()
return return start_figure_num
csv_path = csv_files[0] # 使用找到的第一个CSV文件 csv_path = csv_files[0] # 使用找到的第一个CSV文件