Compare commits
2 Commits
43f50ec07b
...
429ed3c1c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 429ed3c1c1 | |||
| d327c6c267 |
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user