Compare commits

...

2 Commits

Author SHA1 Message Date
DXC
429ed3c1c1 测试修改 2026-06-25 18:11:50 +08:00
DXC
d327c6c267 fix(report_word): Minimax v2 接口兼容 + 缓存防毒化自愈 2026-06-25 16:45:50 +08:00
3 changed files with 1906 additions and 1847 deletions

View File

@ -165,24 +165,43 @@ class BandConfirmDialog(QDialog):
from PyQt5.QtCore import QSettings from PyQt5.QtCore import QSettings
AI_SETTINGS_ORG = "IrisWaterQuality" AI_SETTINGS_ORG = "IrisWaterQuality"
AI_SETTINGS_APP = "WQ_GUI" AI_SETTINGS_APP = "WQ_GUI"
# 扩充预设字典,覆盖市面主流大模型标准接口
AI_DEFAULTS = { AI_DEFAULTS = {
"ollama": { "aliyun": {
"api_base_url": "http://localhost:11434", "api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"vision_model": "qwen3-vl:8b", "vision_model": "qwen-vl-max",
"text_model": "qwen3-vl:8b", "text_model": "qwen-max",
},
"zhipu": {
"api_base_url": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
"vision_model": "glm-4v",
"text_model": "glm-4",
},
"deepseek": {
"api_base_url": "https://api.deepseek.com/chat/completions",
"vision_model": "deepseek-chat", # DeepSeek暂无独立视觉API,可用通用或自行更换
"text_model": "deepseek-chat",
},
"openai": {
"api_base_url": "https://api.openai.com/v1/chat/completions",
"vision_model": "gpt-4o",
"text_model": "gpt-4o",
}, },
"minimax": { "minimax": {
"api_base_url": "https://api.minimaxi.com/v1/text/chatcompletion_v2", "api_base_url": "https://api.minimax.chat/v1/chat/completions",
"vision_model": "abab6.5s-chat", "vision_model": "abab6.5g-chat",
"text_model": "abab6.5s-chat", "text_model": "abab6.5s-chat",
}, },
"ollama": {
"api_base_url": "http://localhost:11434",
"vision_model": "qwen2-vl",
"text_model": "qwen2.5",
},
} }
class AISettingsDialog(QDialog): class AISettingsDialog(QDialog):
"""AI 引擎可视化配置弹窗,配置持久化到 QSettings。""" """AI 引擎可视化配置弹窗,配置持久化到 QSettings。"""
@ -195,41 +214,22 @@ class AISettingsDialog(QDialog):
self._init_ui() self._init_ui()
def _load_settings(self): def _load_settings(self):
"""从 QSettings 读取已有配置;无记录则回退到环境变量或默认值。""" """从 QSettings 读取已有配置;无记录则回退到预设字典或环境变量。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
self._provider = s.value("ai_provider", "minimax", type=str) # 默认推荐 Aliyun (通义千问)
self._provider = s.value("ai_provider", "Aliyun", type=str)
# API Key 不设默认值(敏感信息,首次必须由用户输入) # 【向后兼容补丁】优先读取新规范的 api_key,如果为空,尝试读取旧版本留下的 minimax_api_key
self._api_key = s.value("api_key", "", type=str)
if not self._api_key:
self._api_key = s.value("minimax_api_key", "", type=str) self._api_key = s.value("minimax_api_key", "", type=str)
# 已保存的 URL 和模型;若 QSettings 无记录则读环境变量 provider_key = self._provider.lower()
if self._provider == "ollama": defaults = AI_DEFAULTS.get(provider_key, {"api_base_url": "", "vision_model": "", "text_model": ""})
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._api_base_url = s.value("api_base_url", "", type=str) or defaults.get("api_base_url", "")
self._vision_model = s.value("vision_model", "", type=str) or defaults.get("vision_model", "")
self._text_model = s.value("text_model", "", type=str) or defaults.get("text_model", "")
self._timeout = s.value("timeout_s", 120, type=int) self._timeout = s.value("timeout_s", 120, type=int)
def _init_ui(self): def _init_ui(self):
@ -240,9 +240,15 @@ class AISettingsDialog(QDialog):
provider_row = QHBoxLayout() provider_row = QHBoxLayout()
provider_row.addWidget(QLabel("AI 引擎提供商:")) provider_row.addWidget(QLabel("AI 引擎提供商:"))
self._provider_combo = QComboBox() 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) self._provider_combo.setEditable(True)
self._provider_combo.addItems(["Aliyun", "Zhipu", "DeepSeek", "OpenAI", "Minimax", "Ollama"])
self._provider_combo.setCurrentText(self._provider)
# 当文本改变时自动带出推荐配置
self._provider_combo.currentTextChanged.connect(self._on_provider_changed)
provider_row.addWidget(self._provider_combo, 1) provider_row.addWidget(self._provider_combo, 1)
provider_row.addStretch(1) provider_row.addStretch(1)
layout.addLayout(provider_row) layout.addLayout(provider_row)
@ -251,7 +257,7 @@ class AISettingsDialog(QDialog):
url_row = QHBoxLayout() url_row = QHBoxLayout()
url_row.addWidget(QLabel("API Base URL:")) url_row.addWidget(QLabel("API Base URL:"))
self._url_edit = QLineEdit(self._api_base_url) self._url_edit = QLineEdit(self._api_base_url)
self._url_edit.setPlaceholderText("例如: http://localhost:11434") self._url_edit.setPlaceholderText("填入兼容 OpenAI 规范的完整 URL")
url_row.addWidget(self._url_edit, 1) url_row.addWidget(self._url_edit, 1)
layout.addLayout(url_row) layout.addLayout(url_row)
@ -259,7 +265,7 @@ class AISettingsDialog(QDialog):
key_row = QHBoxLayout() key_row = QHBoxLayout()
key_row.addWidget(QLabel("API Key:")) key_row.addWidget(QLabel("API Key:"))
self._key_edit = QLineEdit(self._api_key) self._key_edit = QLineEdit(self._api_key)
self._key_edit.setPlaceholderText("输入 API Key(敏感信息,已加密存储)") self._key_edit.setPlaceholderText("输入 API Key(本地加密存储)")
self._key_edit.setEchoMode(QLineEdit.Password) self._key_edit.setEchoMode(QLineEdit.Password)
key_row.addWidget(self._key_edit, 1) key_row.addWidget(self._key_edit, 1)
layout.addLayout(key_row) layout.addLayout(key_row)
@ -288,10 +294,10 @@ class AISettingsDialog(QDialog):
# ── 说明 ────────────────────────────────────────────────────────────── # ── 说明 ──────────────────────────────────────────────────────────────
hint = QLabel( hint = QLabel(
"提示:切换引擎后将自动填充推荐默认值(可手动修改)。" "提示:可以直接在下拉框输入任意名称。选择预设服务商会自动填充推荐的兼容接口 URL。\n"
"API Key 仅本地加密存储,不会明文暴露。" "若使用全能多模态大模型(如 gpt-4o / qwen-vl-max 等),视觉与文本模型填入相同名称即可。"
) )
hint.setStyleSheet("color: #888; font-size: 10px;") hint.setStyleSheet("color: #888; font-size: 11px;")
hint.setWordWrap(True) hint.setWordWrap(True)
layout.addWidget(hint) layout.addWidget(hint)
@ -306,10 +312,11 @@ class AISettingsDialog(QDialog):
btn_box.addButton(cancel_btn, QDialogButtonBox.RejectRole) btn_box.addButton(cancel_btn, QDialogButtonBox.RejectRole)
layout.addWidget(btn_box) layout.addWidget(btn_box)
def _on_provider_changed(self): def _on_provider_changed(self, text):
"""切换 Provider 时自动填充推荐默认值。""" """切换或输入 Provider 时自动填充推荐默认值。"""
provider = self._provider_combo.currentText().lower() provider_key = text.lower()
defaults = AI_DEFAULTS.get(provider, AI_DEFAULTS["minimax"]) if provider_key in AI_DEFAULTS:
defaults = AI_DEFAULTS[provider_key]
self._url_edit.setText(defaults["api_base_url"]) self._url_edit.setText(defaults["api_base_url"])
self._vision_edit.setText(defaults["vision_model"]) self._vision_edit.setText(defaults["vision_model"])
self._text_edit.setText(defaults["text_model"]) self._text_edit.setText(defaults["text_model"])
@ -317,7 +324,8 @@ class AISettingsDialog(QDialog):
def _save_and_close(self): def _save_and_close(self):
"""持久化到 QSettings 并关闭。""" """持久化到 QSettings 并关闭。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = self._provider_combo.currentText().lower() # 获取用户输入的文本(无论是选的还是自己打字的)
provider = self._provider_combo.currentText().strip()
s.setValue("ai_provider", provider) s.setValue("ai_provider", provider)
s.setValue("api_base_url", self._url_edit.text().strip()) s.setValue("api_base_url", self._url_edit.text().strip())
s.setValue("api_key", self._key_edit.text().strip()) s.setValue("api_key", self._key_edit.text().strip())
@ -334,11 +342,13 @@ class AISettingsDialog(QDialog):
返回键:ai_provider / api_base_url / api_key / vision_model / text_model / timeout_s 返回键:ai_provider / api_base_url / api_key / vision_model / text_model / timeout_s
""" """
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str) # 默认返回 Aliyun
provider = s.value("ai_provider", "Aliyun", type=str)
return { return {
"ai_provider": provider, "ai_provider": provider,
"api_base_url": s.value("api_base_url", "", type=str), "api_base_url": s.value("api_base_url", "", type=str),
"api_key": s.value("api_key", "", type=str), # 同样向后兼容读取旧版本的 key
"api_key": s.value("api_key", s.value("minimax_api_key", "", type=str), type=str),
"vision_model": s.value("vision_model", "", type=str), "vision_model": s.value("vision_model", "", type=str),
"text_model": s.value("text_model", "", type=str), "text_model": s.value("text_model", "", type=str),
"timeout_s": s.value("timeout_s", 120, type=int), "timeout_s": s.value("timeout_s", 120, type=int),

View File

@ -64,8 +64,9 @@ class ReportWorkerThread(QThread):
) )
else: else:
ai_cfg = ReportGenerationConfig( ai_cfg = ReportGenerationConfig(
ai_provider="minimax", ai_provider=provider,
minimax_api_key=s.value("api_key", "", type=str) or "", minimax_api_key=s.value("api_key", "", type=str) or "",
minimax_base_url=s.value("api_base_url", "", type=str) or None, # <--- 新增这行,把界面上的 URL 传过去
minimax_vision_model=s.value("vision_model", "", type=str) or None, minimax_vision_model=s.value("vision_model", "", type=str) or None,
minimax_text_model=s.value("text_model", "", type=str) or None, minimax_text_model=s.value("text_model", "", type=str) or None,
minimax_timeout_s=timeout, minimax_timeout_s=timeout,

View File

@ -33,6 +33,10 @@ from docx.oxml import OxmlElement
from docx import Document from docx import Document
from docx.shared import Inches, Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.section import WD_SECTION from docx.enum.section import WD_SECTION
from docx.oxml.ns import qn from docx.oxml.ns import qn
@ -87,6 +91,7 @@ class ReportGenerationConfig:
self.on_step = on_step self.on_step = on_step
self.n = 0
self._render() self._render()
@ -160,8 +165,11 @@ class WaterQualityReportGenerator:
# 通用 # 通用
ai_provider: Optional[str] = None # "ollama" | "minimax",默认 "minimax"
os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1/text/chatcompletion_v2").rstrip("/") enable_ai_analysis: Optional[bool] = None
# Ollama 专属
ollama_base_url: Optional[str] = None ollama_base_url: Optional[str] = None
@ -229,10 +237,13 @@ class WaterQualityReportGenerator:
if output_dir is None: if output_dir is None:
f"{param}_distribution_enhanced.png" self.output_dir = self.visualization_dir
else: else:
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
@ -320,10 +331,12 @@ class WaterQualityReportGenerator:
) )
"""调用 Minimax 文本模型 /v1/text/chatcompletion_v2。""" # 接收外部传入的万能 URL,默认给一个国际标准的 completions 端点
self.minimax_base_url = ( self.minimax_base_url = (
cfg.minimax_base_url.rstrip("/")
if cfg and getattr(cfg, 'minimax_base_url', None) if cfg and getattr(cfg, 'minimax_base_url', None)
else os.environ.get("MINIMAX_BASE_URL", "https://api.openai.com/v1/chat/completions").rstrip("/") else os.environ.get("MINIMAX_BASE_URL", "https://api.openai.com/v1/chat/completions").rstrip("/")
@ -332,10 +345,9 @@ class WaterQualityReportGenerator:
self.minimax_vision_model = ( self.minimax_vision_model = (
cfg.minimax_vision_model cfg.minimax_vision_model
url=self.minimax_base_url, if cfg and cfg.minimax_vision_model
else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat") else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat")
@ -348,24 +360,37 @@ class WaterQualityReportGenerator:
if cfg and cfg.minimax_text_model if cfg and cfg.minimax_text_model
else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat") else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat")
return (
obj.get("choices", [{}])[0] )
.get("message", {})
.get("content", "") self.minimax_timeout_s = (
.strip()
or "(模型未返回内容)" 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: else:
self.enable_ai_analysis = os.environ.get("ENABLE_AI_ANALYSIS", "1") not in { self.enable_ai_analysis = os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {
return f"(Minimax调用失败 HTTP {e.code}:{e.reason})"
"0", "0",
return f"(Minimax调用失败:{e})"
"false", "false",
return f"(Minimax解析失败:{e})"
"False", "False",
"""调用 Minimax 视觉模型(多模态),图片转为 base64 后通过 image_url 传入。"""
} }
self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json"
@ -375,6 +400,8 @@ class WaterQualityReportGenerator:
# 各参数的专业描述(完整版) # 各参数的专业描述(完整版)
self.parameter_descriptions = { self.parameter_descriptions = {
"Chlorophyll": """叶绿素(Chlorophyll)是浮游植物进行光合作用的关键色素,直接反映水体中藻类的生物量与初级生产力水平。它是评价水体富营养化程度最常用的指标之一。当叶绿素浓度持续升高时,表明藻类大量增殖,水华风险显著增加,并可能引发溶解氧剧烈波动、水体透明度下降及底栖生态系统退化。因此,通过遥感手段反演叶绿素浓度,可为水华预警、水质改善及生态修复提供重要科学依据。""",
@ -400,7 +427,7 @@ class WaterQualityReportGenerator:
"Turbidity": """浊度(Turbidity)反映水体中悬浮颗粒物(如泥沙、藻类、微生物)对光线的散射程度,是衡量水体透明度的关键指标。浊度升高不仅影响水生植物光合作用,还会为病原微生物提供附着载体,干扰水处理工艺。通过遥感影像反演浊度,可实现大范围、高频次的水体清澈度评价,对饮用水源地保护和河流泥沙输送研究具有重要意义。""", "Turbidity": """浊度(Turbidity)反映水体中悬浮颗粒物(如泥沙、藻类、微生物)对光线的散射程度,是衡量水体透明度的关键指标。浊度升高不仅影响水生植物光合作用,还会为病原微生物提供附着载体,干扰水处理工艺。通过遥感影像反演浊度,可实现大范围、高频次的水体清澈度评价,对饮用水源地保护和河流泥沙输送研究具有重要意义。""",
url=self.minimax_base_url,
"TDS": """总溶解固体(TDS)指水中溶解性无机盐和部分有机物的总质量,与水的适口性、管道腐蚀风险及灌溉适宜性密切相关。TDS过高会导致水味苦涩,并可能伴随有害微量元素积累。在咸潮入侵、工业排放及农业面源污染研究中,TDS是评价水质变化的稳定指标,其反演结果有助于识别淡水咸化区域及制定取水策略。""", "TDS": """总溶解固体(TDS)指水中溶解性无机盐和部分有机物的总质量,与水的适口性、管道腐蚀风险及灌溉适宜性密切相关。TDS过高会导致水味苦涩,并可能伴随有害微量元素积累。在咸潮入侵、工业排放及农业面源污染研究中,TDS是评价水质变化的稳定指标,其反演结果有助于识别淡水咸化区域及制定取水策略。""",
@ -413,22 +440,35 @@ class WaterQualityReportGenerator:
"NO3-N": """硝酸盐氮(NO3-N)是氮循环中氧化程度最高的形态,易溶于水,常通过农田径流、化粪池渗漏或工业废水进入水体。过量硝酸盐会刺激藻类过度生长,加速水体富营养化;饮用水中硝酸盐氮浓度超标会引发“蓝婴症”(高铁血红蛋白血症),对婴幼儿健康构成威胁。因此,硝酸盐氮是水质评价与饮用水安全监管的重点指标。""", "NO3-N": """硝酸盐氮(NO3-N)是氮循环中氧化程度最高的形态,易溶于水,常通过农田径流、化粪池渗漏或工业废水进入水体。过量硝酸盐会刺激藻类过度生长,加速水体富营养化;饮用水中硝酸盐氮浓度超标会引发“蓝婴症”(高铁血红蛋白血症),对婴幼儿健康构成威胁。因此,硝酸盐氮是水质评价与饮用水安全监管的重点指标。""",
return (
obj.get("choices", [{}])[0] "NH3-N": """氨氮(NH3-N)是水体受有机污染初期的重要指示物,主要来源于生活污水、农业化肥及工业含氮废水。氨氮对鱼类等水生生物有较强的毒性,且在好氧条件下会消耗大量溶解氧转化为硝酸盐。氨氮浓度高往往反映近期污染输入或水体自净能力不足,其动态变化可用于预警突发性污染事件和评估生态修复效果。""",
.get("message", {})
.get("content", "")
.strip()
or "(模型未返回内容)" "BGA": """BGA(蓝绿藻,即蓝藻)是表征水体蓝藻生物量的关键生物参数,通常通过藻蓝蛋白等特征色素反演获得。蓝藻过量繁殖(水华)会释放藻毒素、消耗溶解氧、形成水面覆盖层,严重威胁饮用水安全和水生态系统健康。BGA浓度的空间分布能精准指示水华高发区域与迁移路径,是水华预警、蓝藻治理和生态修复措施制定不可或缺的输入信息。""",
)
"TT": """总氮(TT)是水体中有机氮、氨氮、硝酸盐氮、亚硝酸盐氮等各种形态氮的总和,综合反映了水体的氮营养水平。总氮是导致水体富营养化的主要限制因子之一,其浓度过高会引发藻类爆发、透明度下降、水质恶化。总氮的时空变化趋势可用于判断流域面源污染强度、评估氮减排措施成效,是水质管理和流域水环境保护的关键参考指标。"""
}
# 每个参数对应的图片顺序(统一5张图模式)
params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature",
"spCond", "Turbidity", "TDS", "Cl-", "NO3-N",
"NH3-N", "BGA", "TT"] "NH3-N", "BGA", "TT"]
self.parameter_images = { self.parameter_images = {
return f"(Minimax Vision调用失败 HTTP {e.code}:{e.reason})"
param: [ param: [
return f"(Minimax Vision调用失败:{e})"
f"{param}_histogram.png", f"{param}_histogram.png",
return f"(Minimax Vision解析失败:{e})"
f"{param}_spectrum_comparison.png", f"{param}_spectrum_comparison.png",
f"{param}_scatter_with_confidence.png", f"{param}_scatter_with_confidence.png",
@ -440,13 +480,13 @@ class WaterQualityReportGenerator:
] for param in params_list ] for param in params_list
} }
if self.ai_provider == "minimax":
# ========== 新增:缓存线程锁 ========== # ========== 新增:缓存线程锁 ==========
self._cache_lock = Lock() self._cache_lock = Lock()
else:
return self._ollama_chat(model, system_prompt, user_prompt, image_path)
def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None:
@ -611,28 +651,42 @@ class WaterQualityReportGenerator:
return (obj.get("message") or {}).get("content", "").strip() or "(模型未返回内容)" return (obj.get("message") or {}).get("content", "").strip() or "(模型未返回内容)"
"""分析单张图片并缓存,失败则返回可展示的提示文本。""" except (HTTPError, URLError, TimeoutError) as e:
return f"(Ollama调用失败:{e})" return f"(Ollama调用失败:{e})"
except Exception as e: except Exception as e:
cache = self._load_ai_cache() return f"(Ollama解析失败:{e})"
cache_key = f"{image_path.name}::{image_path.stat().st_mtime_ns}::{self.ollama_vision_model}::{image_type}"
if cache_key in cache:
return str(cache[cache_key])
def _call_minimax_text(self, system_prompt: str, user_prompt: str) -> str:
"""调用 Minimax 文本模型(自动兼容 OpenAI 标准端点)"""
if not self.minimax_api_key:
return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)"
url = self.minimax_base_url
payload: Dict[str, Any] = {
"model": self.minimax_text_model,
"max_tokens": 4096, "max_tokens": 4096,
"messages": [ "messages": [
model=self.ollama_vision_model, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
} }
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = Request(
url=url,
data=data,
headers={
"Authorization": f"Bearer {self.minimax_api_key}",
"Content-Type": "application/json",
}, },
method="POST", method="POST",
return text
) )
try: try:
with urlopen(req, timeout=self.minimax_timeout_s) as resp: with urlopen(req, timeout=self.minimax_timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="ignore") raw = resp.read().decode("utf-8", errors="ignore")
@ -828,102 +882,103 @@ class WaterQualityReportGenerator:
}, },
self, "spectrum_comparison": {
doc,
param: str,
vis_dir: Path,
param_index: int = 1,
start_figure_num: int = 1,
all_image_analyses: Optional[List[Dict[str, Any]]] = None,
progress=None,
"analysis": ( "analysis": (
"""为单个参数添加报告章节(带编号和规范中英文图题)"""
"分析要点:\n" "分析要点:\n"
print(f"警告: 参数 {param} 没有预定义的描述")
f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n" f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n"
"- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n" "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n"
"- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。" "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。"
# 添加参数描述
), ),
# 设置首行缩进两个字符(中文排版规范)
# 设置正文样式:宋体小四,1.5倍行距
"conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。", "conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。",
}, },
"scatter_with_confidence": { "scatter_with_confidence": {
# 添加图片 - 支持子文件夹结构 + 中英文图题
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"histogram": "直方图",
"spectrum_comparison": "光谱对比图", "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n"
"scatter_with_confidence": "模型散点图",
"boxplot": "箱型图",
"distribution": "分布图"
"- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n" "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n"
"- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。"
),
"conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。",
}, },
"boxplot": {
# 选择子文件夹与动态寻址
"analysis": ( "analysis": (
img_path = vis_dir / img_name
"分析要点:\n"
"- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n" "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n"
if not img_path.exists(): "- 解释离散程度大(箱体长)可能代表的强烈时空异质性。\n"
img_path = vis_dir / img_name
"- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。" "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。"
img_path = vis_dir / img_name
),
"conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。", "conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。",
}, },
# 颜色地图由预测步骤生成,开启全盘指索 "distribution": {
search_dirs = [
vis_dir.parent / "9_water_quality_prediction",
vis_dir.parent / "11_12_13_predictions",
vis_dir.parent / "9_Concentration" / "charts",
vis_dir.parent / "9_Concentration",
vis_dir
]
"analysis": ( "analysis": (
candidates = list(s_dir.glob(f"*{param}*.png")) + list(s_dir.glob(f"*{param}*.jpg")) "分析要点:\n"
# 剔除掉属于其他类型的图
candidates = [c for c in candidates if not any(x in c.name.lower() for x in ("scatter", "histogram", "spectrum", "boxplot", "preview"))]
f"- 分析 {param} 高值区与低值区的空间异质性特征。\n" f"- 分析 {param} 高值区与低值区的空间异质性特征。\n"
"- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n" "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n"
"- 结合常见水动力学特征,简述物质可能的输移趋势。" "- 结合常见水动力学特征,简述物质可能的输移趋势。"
img_path = vis_dir / img_name
), ),
"conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。", "conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。",
}, },
"correlation_heatmap": {
"analysis": (
"分析要点:\n"
"- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等。\n"
"- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)。\n"
"- 基于相关性聚类,推断水体中的核心主导污染因子群。"
),
"conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。",
},
}
# 使用统一的图像插入方法(中文图题)
default_spec = { default_spec = {
"analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。", "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。",
# 添加英文图题:宋体小四(与中文图题一致)
"conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。", "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。",
@ -933,24 +988,17 @@ class WaterQualityReportGenerator:
spec = type_specs.get(image_type, default_spec) spec = type_specs.get(image_type, default_spec)
# AI 分析:插入在图题之后
image_path=img_path, user = (
image_type=title_key,
param=param,
figure_num=figure_num,
f"图号:图{figure_num}\n" f"图号:图{figure_num}\n"
all_image_analyses.append( f"当前分析参数:{param}\n"
{
"figure_num": figure_num, f"图表类型:{image_type}\n\n"
"param": param,
"image_type": title_key,
"image_name": img_name,
} "【专业要求】:\n"
)
f"{spec['analysis']}\n\n" f"{spec['analysis']}\n\n"
@ -959,14 +1007,13 @@ class WaterQualityReportGenerator:
"直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n" "直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n"
f"【最终落脚点要求】:{spec['conclusion']}\n" f"【最终落脚点要求】:{spec['conclusion']}\n"
# 每处理完一张图(无论成功/失败)更新进度条
) )
return {"system": system, "user": user} return {"system": system, "user": user}
doc.add_paragraph() # 章节结束空行
@ -1241,7 +1288,7 @@ class WaterQualityReportGenerator:
report_title: str = "水质参数反演分析报告", report_title: str = "水质参数反演分析报告",
output_path: Optional[str] = None, output_path: Optional[str] = None,
deglint_img_path = vis_dir / "glint_deglint_previews" / "deglint_deglint_goodman_preview.png"
on_progress=None) -> str: on_progress=None) -> str:
""" """
@ -1734,7 +1781,7 @@ class WaterQualityReportGenerator:
for run in title.runs: for run in title.runs:
run.font.name = self.title_font run.font.name = self.title_font
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff']
run.font.size = Pt(36) # 增大标题字体 run.font.size = Pt(36) # 增大标题字体
run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font)
@ -1787,3 +1834,4 @@ if __name__ == "__main__":
run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font)