diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index ba22b71..eb88b2e 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -6,6 +6,7 @@ 与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。 """ +import os from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( @@ -17,6 +18,8 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QDialogButtonBox, QWidget, + QComboBox, + QLineEdit, ) @@ -145,3 +148,189 @@ class BandConfirmDialog(QDialog): """点"取消运行"触发:停表 + 关闭,调用方需中止流程""" self._timer.stop() 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), + } diff --git a/src/gui/panels/report_generation_panel.py b/src/gui/panels/report_generation_panel.py index 8029e9b..5cffd07 100644 --- a/src/gui/panels/report_generation_panel.py +++ b/src/gui/panels/report_generation_panel.py @@ -9,14 +9,15 @@ import traceback from pathlib import Path from typing import Optional -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, - QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox, + QLabel, QCheckBox, QPushButton, QLineEdit, QMessageBox, QFileDialog, ) from src.gui.styles import ModernStylesheet +from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP class ReportGenerateThread(QThread): @@ -25,35 +26,43 @@ class ReportGenerateThread(QThread): failed = pyqtSignal(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__() self.work_dir = work_dir self.output_dir = output_dir self.report_title = report_title - self.options = options + self.enable_ai = enable_ai 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") + # 唯一数据源:直接从 QSettings 读取 AI 配置 + s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) + provider = s.value("ai_provider", "minimax", type=str) + timeout = int(s.value("timeout_s", 120, type=int)) + + if provider == "ollama": + 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( - f"报告生成:工作目录={self.work_dir},AI={'开' if enable_ai else '关'}," - f"模型URL={url or '(环境变量 OLLAMA_URL)'}", + f"报告生成:工作目录={self.work_dir},AI={'开' if self.enable_ai else '关'},Provider={provider}", "info", ) gen = WaterQualityReportGenerator( @@ -71,12 +80,13 @@ class ReportGenerateThread(QThread): class ReportGenerationPanel(QWidget): - """Word 报告生成:工作目录、输出目录、Ollama URL/模型、是否启用 AI 等。""" + """Word 报告生成面板。AI 配置统一由 AISettingsDialog 管理,本面板不持有配置状态。""" def __init__(self, main_window=None, parent=None): super().__init__(parent) self.main_window = main_window self._report_thread = None + self._ai_label = None self.init_ui() def init_ui(self): @@ -86,7 +96,7 @@ class ReportGenerationPanel(QWidget): intro = QLabel( "根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。" - "需已存在可视化图表;AI 分析通过 Ollama /api/chat 调用本地或远程服务。" + "需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。" ) intro.setWordWrap(True) intro.setStyleSheet( @@ -94,6 +104,7 @@ class ReportGenerationPanel(QWidget): ) layout.addWidget(intro) + # ── 路径 ────────────────────────────────────────────────────────────── path_group = QGroupBox("路径") path_form = QFormLayout() @@ -125,52 +136,32 @@ class ReportGenerationPanel(QWidget): path_group.setLayout(path_form) layout.addWidget(path_group) - ai_group = QGroupBox("AI 分析(Ollama)") - ai_form = QFormLayout() + # ── AI 分析 ─────────────────────────────────────────────────────────── + ai_group = QGroupBox("AI 分析") + ai_layout = QVBoxLayout() 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) + ai_layout.addWidget(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) + # 只读提示行:当前引擎 + 配置按钮 + ai_status_row = QHBoxLayout() + ai_status_row.addWidget(QLabel("当前 AI 引擎:")) + self._ai_label = QLabel() + 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() - 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) + ai_group.setLayout(ai_layout) layout.addWidget(ai_group) + # ── 按钮 ────────────────────────────────────────────────────────────── btn_row = QHBoxLayout() self.generate_btn = QPushButton("生成 Word 报告") self.generate_btn.setStyleSheet( @@ -184,16 +175,21 @@ class ReportGenerationPanel(QWidget): 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()) + # 刷新引擎提示文字 + self._refresh_ai_label() - 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 _refresh_ai_label(self): + """从 QSettings 读取当前 Provider 并更新只读标签。""" + s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) + provider = s.value("ai_provider", "minimax", type=str) + 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): """获取 work_dir,优先用主窗口缓存的 work_dir""" @@ -227,15 +223,11 @@ class ReportGenerationPanel(QWidget): self.work_dir_edit.setText(str(work_dir)) def get_config(self): + """返回路径和标题配置(AI 配置不由本面板持有)。""" 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(), } @@ -248,16 +240,6 @@ class ReportGenerationPanel(QWidget): 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"])) @@ -280,16 +262,10 @@ class ReportGenerationPanel(QWidget): 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(), - } + enable_ai = self.enable_ai_cb.isChecked() + 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.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection) self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection) @@ -312,4 +288,4 @@ class ReportGenerationPanel(QWidget): def _on_report_fail(self, err: str): QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}") - self._forward_log(err, "error") + self._forward_log(err, "error") \ No newline at end of file diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py index 95f1e1d..0b88f03 100644 --- a/src/gui/water_quality_gui.py +++ b/src/gui/water_quality_gui.py @@ -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_75_panel import Step8_75Panel 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.report_generation_panel import ReportGenerationPanel @@ -1684,7 +1684,12 @@ class WaterQualityGUI(QMainWindow): open_dir_action.triggered.connect(self.open_work_directory) 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.triggered.connect(self.auto_populate_all_steps) @@ -2745,6 +2750,11 @@ class WaterQualityGUI(QMainWindow): "邮箱:hanshanlong@iris-rs.cn\n" ) + def _show_ai_settings(self): + """弹出 AI 引擎配置对话框。""" + dlg = AISettingsDialog(self) + dlg.exec_() + def _precheck_step3_bands(self) -> bool: """步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑) diff --git a/src/postprocessing/report_word.py b/src/postprocessing/report_word.py index 1cc7e00..a572b9f 100644 --- a/src/postprocessing/report_word.py +++ b/src/postprocessing/report_word.py @@ -63,14 +63,23 @@ class _SimpleProgress: @dataclass class ReportGenerationConfig: """ - 报告生成与 Ollama AI 分析的可选配置。 - 未设置的字段沿用环境变量(OLLAMA_*、ENABLE_AI_ANALYSIS)或生成器默认值。 + 报告生成与 AI 分析的可选配置。 + 支持 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_vision_model: Optional[str] = None ollama_text_model: Optional[str] = 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: @@ -105,7 +114,14 @@ class WaterQualityReportGenerator: self.english_font = 'Times New Roman' # 英文 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("/") self.ollama_base_url = ( cfg.ollama_base_url.rstrip("/") @@ -127,6 +143,33 @@ class WaterQualityReportGenerator: if cfg and cfg.ollama_timeout_s is not None 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: self.enable_ai_analysis = bool(cfg.enable_ai_analysis) else: @@ -262,8 +305,10 @@ class WaterQualityReportGenerator: } def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: - """在已创建的生成器上更新 AI 相关设置(下次 _ollama_chat 生效)。""" + """在已创建的生成器上更新 AI 相关设置(下次 _ai_chat 生效)。""" cfg = ai_config + if cfg.ai_provider: + self.ai_provider = cfg.ai_provider.lower() if cfg.ollama_base_url: self.ollama_base_url = cfg.ollama_base_url.rstrip("/") if cfg.ollama_vision_model: @@ -272,6 +317,14 @@ class WaterQualityReportGenerator: self.ollama_text_model = cfg.ollama_text_model if cfg.ollama_timeout_s is not None: 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: self.enable_ai_analysis = bool(cfg.enable_ai_analysis) @@ -337,6 +390,133 @@ class WaterQualityReportGenerator: except Exception as 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]: """按图片类型返回 system/user 提示词,带防幻觉约束。""" system = ( @@ -545,7 +725,7 @@ class WaterQualityReportGenerator: return str(cache[cache_key]) 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, system_prompt=prompts["system"], 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, @@ -662,7 +842,7 @@ class WaterQualityReportGenerator: base_section_num = 5 last_param_section_num = base_section_num + len(parameters) - 1 for section_num, param in enumerate(parameters, base_section_num): - self._add_parameter_section( + figure_counter = self._add_parameter_section( doc, param, vis_dir, @@ -671,7 +851,6 @@ class WaterQualityReportGenerator: all_image_analyses, progress=progress, ) - figure_counter += len(self.parameter_images.get(param, [])) if section_num != last_param_section_num: doc.add_page_break() @@ -700,7 +879,7 @@ class WaterQualityReportGenerator: "- 不要编造具体数值、地名、日期\n\n" 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.paragraph_format.first_line_indent = Pt(24) para.paragraph_format.line_spacing = 1.5 @@ -741,7 +920,7 @@ class WaterQualityReportGenerator: """为单个参数添加报告章节(带编号和规范中英文图题)""" if param not in self.parameter_descriptions: print(f"警告: 参数 {param} 没有预定义的描述") - return + return start_figure_num # 添加带编号的参数标题 heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) @@ -851,6 +1030,7 @@ class WaterQualityReportGenerator: pass doc.add_paragraph() # 章节结束空行 + return start_figure_num + len(image_list) def _add_cover_page(self, doc): """添加专业的封面页 - 优化后的布局""" @@ -1188,9 +1368,9 @@ class WaterQualityReportGenerator: 请用专业且简洁的语言描述,控制在150字以内。""" 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(): - 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: return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。" @@ -1231,7 +1411,7 @@ class WaterQualityReportGenerator: ... 各架次轨迹分布合理,覆盖了目标水体区域。""" - result = self._ollama_chat( + result = self._ai_chat( self.ollama_vision_model, "你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。", analysis_prompt, @@ -1283,7 +1463,7 @@ class WaterQualityReportGenerator: 【示例输出】 水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征""" - result = self._ollama_chat( + result = self._ai_chat( self.ollama_vision_model, "你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。", analysis_prompt, @@ -1337,7 +1517,7 @@ class WaterQualityReportGenerator: 请根据图像内容给出专业分析。""" - result = self._ollama_chat( + result = self._ai_chat( self.ollama_vision_model, "你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。", analysis_prompt, @@ -1456,13 +1636,13 @@ class WaterQualityReportGenerator: if not processed_data_dir.exists(): doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") doc.add_page_break() - return - + return start_figure_num + csv_files = list(processed_data_dir.glob("*.csv")) if not csv_files: doc.add_paragraph(f"在 {processed_data_dir} 目录下未找到CSV统计数据文件。") doc.add_page_break() - return + return start_figure_num csv_path = csv_files[0] # 使用找到的第一个CSV文件