feat(report): 支持 Minimax AI 后端 + 统一 AI 配置对话框,修复 figure_counter 返回值断链 Bug
This commit is contained in:
@ -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文件
|
||||
|
||||
|
||||
Reference in New Issue
Block a user