Commit bd79c9e5 authored by wangteng's avatar wangteng

模型更换

parent bbee8108
......@@ -16,6 +16,8 @@ web/configs/llm.yaml
web/configs/db_defaults.yaml
web/backend/configs/llm.yaml
workflow/config.yaml
backend-deploy.tgz
frontend-deploy.tgz
# ── web 运行时数据 ──
web/data/
......
#!/usr/bin/env python3
"""db-tools 发版:上传 → 备份旧版 → 解压 → 重启 → 验证。
不动 /opt/app/db-tool/web/data/(web.db、backups、backup-config.json、sources)
不动服务器自己的 configs/llm.yaml(tar 里就没有)"""
import sys, os, paramiko
后端发布包包含私有 configs/llm.yaml,部署后无需再配置服务器环境变量。"""
import os
import sys
import paramiko
HOST, USER = "192.168.20.175", "root"
pw = os.environ.get("DBTOOL_PW")
......@@ -26,7 +29,7 @@ sftp = c.open_sftp()
sftp.put("backend-deploy.tgz", "/tmp/dbtool-backend.tgz")
sftp.put("frontend-deploy.tgz", "/tmp/dbtool-frontend.tgz")
sftp.close()
print("uploaded both tarballs\n")
print("uploaded both deployment packages\n")
# 0) 部署前快照(证明 data/ 没被碰)
run("md5sum /opt/app/db-tool/web/data/web.db; ls /opt/app/db-tool/web/data/backups | wc -l")
......@@ -36,10 +39,13 @@ run("TS=$(date +%Y%m%d-%H%M%S) && mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/backend /opt/app/db-tool/releases/$TS/backend && "
"cp -a /opt/web/db-tool /opt/app/db-tool/releases/$TS/frontend && echo backup at $TS")
# 2) 解压后端(覆盖 backend/,不动 data/、logs/、服务器 llm.yaml)
# 2) 解压后端(不动 data/、logs/),并锁定包内私有 LLM 配置权限
run("tar xzf /tmp/dbtool-backend.tgz -C /opt/app/db-tool/web/ && "
"find /opt/app/db-tool/web/backend -name __pycache__ -type d -exec rm -rf {} + ; "
"ls /opt/app/db-tool/web/backend/configs/")
"test -f /opt/app/db-tool/web/backend/configs/llm.yaml && "
"chmod 600 /opt/app/db-tool/web/backend/configs/llm.yaml && "
"sed -E -e 's/^(api_key:[[:space:]]*).*/\\1[REDACTED]/' "
"/opt/app/db-tool/web/backend/configs/llm.yaml")
# 3) 前端:清旧 assets 再解压(避免旧 hash 文件堆积)
run("rm -rf /opt/web/db-tool/assets && tar xzf /tmp/dbtool-frontend.tgz -C /opt/web/db-tool/ && ls /opt/web/db-tool/ /opt/web/db-tool/assets/")
......
......@@ -5,31 +5,13 @@
# 真实 llm.yaml 已在 .gitignore,不会上传到仓库。
# ============================================================
# ── 方式 1:MiniMax(推荐本项目用户使用) ──
provider: minimax
api_key: sk-请填入你的_MiniMax_API_Key
base_url: https://api.minimaxi.com/anthropic
model: MiniMax-Text-01
# ── 方式 2:Anthropic Claude ──
# provider: anthropic
# api_key: sk-ant-xxxxx
# model: claude-sonnet-5
# (base_url 留空,用 Anthropic 官方)
# ── 方式 3:OpenAI ──
# provider: openai
# api_key: sk-xxxxx
# model: gpt-4o
# base_url: https://api.openai.com/v1
# ── 方式 4:自部署 / 第三方代理(OpenAI 兼容) ──
# provider: openai
# api_key: 自定义
# base_url: https://your-proxy.com/v1
# model: your-model-name
# ── 公司内部 Anthropic 兼容转换服务 ──
# 推荐在服务环境中设置 ANTHROPIC_AUTH_TOKEN,不要将真实令牌写入仓库。
# api_key: sk-请填入服务令牌
base_url: http://192.168.40.199:8080
model: glm-5.3-flash
# 生成参数(通用)
max_tokens: 1024
temperature: 0.2
timeout: 30
\ No newline at end of file
timeout: 30
......@@ -4,7 +4,7 @@
用自然语言解释「这条数据为什么不合规」,并审查「规则描述与实现代码是否一致」。
设计:
- 强制 LLM 输出 JSON(用 complete_json),失败兜底走自然语言
- 强制 LLM 输出 JSON;格式异常时展示模型原文
- JSON 字段定义:
violation_reason: 自然语言违规原因
consistency: 'match' | 'code_bug' | 'rule_ambiguous'
......@@ -23,57 +23,20 @@ logger = get_logger("backend.ai_explain")
_SYSTEM_PROMPT = (
"你是数据质量分析助手。用户的诉求是:当一条数据被判为不合规时,希望从两个角度得到答案——"
"1) 这条数据哪里错了(必须对照规则的【自然语言描述】来回答,方便业务人员理解);"
"2) 规则的【自然语言描述】与【实现代码】是否一致——实现代码可能写错。"
"请用中文思考与回答。先把分析想清楚,再用严格的 JSON 格式输出最终结论。"
"你是数据质量分析助手。直接返回 JSON,不要输出思考过程、Markdown 或额外文字。"
)
_USER_PROMPT_TEMPLATE = """请分析下面这条「被判为不合规」的数据。
_USER_PROMPT_TEMPLATE = """分析这条被判为不合规的数据,并只返回 JSON。
字段:{field_key}({field_comment})
原始值:{value_repr}
规则说明:{rule_desc}
规则实现:{code_block}
【字段】
- 列名(数据库列):{field_key}
- 字段说明(数据库注释,可空):{field_comment}
输出:
{{"violation_reason":"用中文说明具体不合规点,80字以内","consistency":"match|code_bug|rule_ambiguous","consistency_detail":"说明规则描述与实现是否一致,60字以内"}}
【数据】
- 原始值(从数据库读出来的,可能是字符串 / 数字 / NULL,请按真实类型理解):{value_repr}
【规则描述】(自然语言 —— 作者写的约束条件,**业务人员用这个理解规则**)
{rule_desc}
【规则代码】(实现该规则的代码:regex 字符串 或 Python 函数 —— **可能写错**)
{code_block}
请做两件事。
═══ 任务 1:违规原因(violation_reason)═══
- **必须基于「规则描述」(自然语言)回答,不是基于代码** —— 业务人员看不懂代码,要的是用人话讲清楚
- 指出原始值中具体哪个位置 / 子串 / 数值 / 字段违反了「规则描述」里的哪一条要求
- 例:「按规则描述要求,身份证号长度应为 18 位;而这条原始值实际为 19 位(多了一个空格),因此被判为不合规」
- 如果「规则描述」本身写得比较宽松但代码判得很严,那是代码的问题(记到任务 2),这里仍按描述如实回答
- 如果原始值是 NULL / 空,先说明「该值为空」再继续
═══ 任务 2:描述 vs 代码一致性(consistency)═══
- "match":代码严格实现了「规则描述」的全部约束 → 数据违规纯粹是数据本身的问题
- "code_bug":「规则描述」清楚明确,但代码有 bug(漏条件 / 判断反了 / 范围不对 /
边界写错 / 该用 AND 写成 OR 等)→ 在 consistency_detail 里**明确指出代码哪一行 /
哪一段不符合规则描述**,并给出修复建议(最好附修改后的代码片段)
- "rule_ambiguous":代码本身没明显 bug,但「规则描述」有歧义 / 缺失 / 前后矛盾 /
用语模糊 → 在 consistency_detail 里指出歧义点(用户未必能自己看出来)
═══ 输出格式(严格 JSON)═══
- 严格按下面的 JSON 结构输出,**不要** Markdown 代码块(不要 ```json 包裹),**不要** 任何额外文字
- 字符串值里如果需要引号,**必须用中文全角引号「」或转义 \\",绝不能用裸的 ASCII 双引号**(这是历史上最容易踩的坑)
- consistency 只能取上面三个值之一;其他值会被前端视为「未明确」
```json
{{
"violation_reason": "用一段中文自然语言回答任务 1",
"consistency": "match" | "code_bug" | "rule_ambiguous",
"consistency_detail": "针对任务 2 的补充说明(match 时可写"无")"
}}
```"""
若规则说明不足以判断,consistency 使用 rule_ambiguous。"""
# 重试时附加的「修复 JSON」提示 —— 短而直接,避免 LLM 二次跑飞
......@@ -168,11 +131,10 @@ def explain_issue(
note: 失败原因 / 解析回退说明 / 重试说明
attempts: 实际调 LLM 几次(1~3,给前端展示)
2026-08-24 重试策略:JSON 解析失败 → 附带 _JSON_FIX_PROMPT 再调一次,
最多 3 次;超过仍失败 → 降级把原文塞 violation_reason + consistency='unknown',
不抛异常、不阻断弹框。
JSON 解析失败时展示模型原文,不进行额外模型请求,避免分析弹窗长时间等待。
"""
MAX_RETRIES = 3
# 分析弹窗应快速返回。格式异常时回显模型原文,不再发起额外模型请求。
MAX_RETRIES = 1
if rule_type not in RULE_TYPES:
return {
......@@ -222,7 +184,9 @@ def explain_issue(
)
try:
text = client.complete(prompt, system=_SYSTEM_PROMPT, json_mode=True)
text = client.complete(
prompt, system=_SYSTEM_PROMPT, json_mode=True, max_tokens=512
)
except LLMUnavailable as e:
# 不可用(无 key / 网络挂 / 限流)—— 不再重试,立即降级
logger.warning(f"[ai_explain] LLM 调用失败(attempt={attempt}):{e}")
......@@ -288,4 +252,4 @@ def _diagnose_json_failure(text: str) -> str:
bad_quote = re.search(r':\s*"[^"]*"\s*"', s)
if bad_quote:
return "疑似 value 里有未转义的 ASCII 双引号"
return "JSON 结构不合法(具体行号见日志)"
\ No newline at end of file
return "JSON 结构不合法(具体行号见日志)"
......@@ -161,7 +161,7 @@ def gen_regex(desc: str) -> tuple[str, str]:
prompt = _USER_TEMPLATE_FIX.format(
desc=desc, bad_regex=bad_regex, error_msg=error_msg
)
text = client.complete(prompt, system=_SYSTEM_PROMPT)
text = client.complete(prompt, system=_SYSTEM_PROMPT, max_tokens=512)
last_regex = _extract_regex(text)
ok, err = _validate_regex(last_regex)
if ok:
......@@ -178,4 +178,4 @@ def gen_regex(desc: str) -> tuple[str, str]:
logger.warning(f"AI 正则:LLM 第 {attempt} 次调用失败 desc={desc!r} err={e}")
return "", f"LLM 调用失败:{e}"
return "", f"LLM 重试 {MAX_TRIES} 次仍未生成合法正则(最后错误:{error_msg}),请补充描述或手动填写"
\ No newline at end of file
return "", f"LLM 重试 {MAX_TRIES} 次仍未生成合法正则(最后错误:{error_msg}),请补充描述或手动填写"
......@@ -38,23 +38,16 @@ from web.backend.core.ai_regex import gen_regex, test_regex # noqa: E402,F401
# ── System Prompt ───────────────────────────────────────
_SYSTEM_PROMPT = "你是 Python 程序员,擅长写简短的数据校验函数。"
_DESCRIPTION_SYSTEM_PROMPT = "你是数据质量规则专家,擅长将简短的规则名称整理为准确、可执行的校验说明。"
_DESCRIPTION_SYSTEM_PROMPT = "你是数据质量规则专家,擅长用一句简洁的话说明数据校验规则。"
_DESCRIPTION_TEMPLATE = """请为下面的数据校验规则生成一条详细、可执行且便于业务人员阅读的规则说明。
规则名称:「{name}」
AI 生成提示词(最高优先级):「{prompt}」
现有规则说明(可能为空或不完整):「{desc}」
_DESCRIPTION_TEMPLATE = """用一句中文生成 30-40 字规则说明,只输出说明本身。
规则名称:{name}
优先依据:{prompt}
现有说明:{desc}
规则类型:{rule_type}
仅保留生成规则所需的关键格式或范围,不要补充额外校验细节。"""
要求:
1. 只输出规则说明本身,不要标题、序号、引号或 Markdown
2. 优先严格依据 AI 生成提示词,写清楚数据需要满足的格式、长度、字符范围、取值范围、日期范围、枚举值或校验关系
3. 对身份证、手机号、统一社会信用代码、银行卡号等常见证件/号码,名称或提示词已经明确其类型时必须写出标准的基础结构。例如中国大陆居民身份证至少说明:共 18 位、前 17 位为数字、最后一位为数字或 X/x;手机号至少说明 11 位数字及号码段要求(若提示词指定)。
4. 行政区划码、出生日期、校验码等额外校验,只有提示词明确要求时才加入;不要编造提示词中没有的业务阈值、枚举值或校验算法。说明中的每一项约束都必须能够由随后生成的规则实现
5. 使用中文,建议 80 到 250 字;信息较少时可简短,但不能只重复规则名称
6. 这段说明会直接展示在校验结果列表中,应使业务人员无需查看正则或代码也能判断不合规原因
"""
_MAX_AI_DESCRIPTION_LENGTH = 40
# ── User Prompt ─────────────────────────────────────────
......@@ -227,6 +220,7 @@ def gen_rule_description(name: str, desc: str, rule_type: str, prompt: str = "")
rule_type=rule_type,
),
system=_DESCRIPTION_SYSTEM_PROMPT,
max_tokens=512,
)
except LLMUnavailable as e:
logger.warning(f"AI 规则说明生成失败 name={name!r} err={e}")
......@@ -238,7 +232,9 @@ def gen_rule_description(name: str, desc: str, rule_type: str, prompt: str = "")
description = description.replace("\n", " ").strip().strip('"')
if not description:
return "", "AI 未生成规则说明"
return description[:500], "已生成详细规则说明"
# 兜底截断,避免模型未遵守长度要求时撑大规则列表。
description = description[:_MAX_AI_DESCRIPTION_LENGTH].rstrip(",、;:。 ")
return description, "已生成简短规则说明(30-40字)"
# ── 主入口 ─────────────────────────────────────────────
......@@ -287,7 +283,7 @@ def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
prompt = tpl_fix.format(
desc=desc, bad_code=bad_code, error_msg=error_msg
)
text = client.complete(prompt, system=_SYSTEM_PROMPT)
text = client.complete(prompt, system=_SYSTEM_PROMPT, max_tokens=512)
last_code = _extract_code(text)
ok, err = validate_user_code(last_code)
if ok:
......
"""LLM 客户端封装(支持 MiniMax / Anthropic / OpenAI)
"""LLM 客户端封装(公司内部 Anthropic 兼容协议)
直接拷贝自 web/core/llm.py,2026-08-20 接入 web 用作「AI 生成正则」。
改动点:default_config_path() 改成读 web/backend/configs/llm.yaml,其他不动,
......@@ -7,13 +7,9 @@
所有方法都设计为可降级 — 如果 LLM 不可用(无 API Key / 超时 / 解析失败),返回 None 或 fallback 值,
不阻断主流程。
支持的 Provider:
- minimax: MiniMax(M2.7 / MiniMax-Text-01 等)— 通过 Anthropic 兼容接口
- anthropic: Claude(官方 Anthropic SDK)
- openai: GPT(OpenAI 兼容协议)
配置优先级(从高到低):
1. 环境变量(最高):LLM_PROVIDER / LLM_API_KEY / LLM_BASE_URL / LLM_MODEL
1. 环境变量(最高):LLM_API_KEY / LLM_BASE_URL / LLM_MODEL,
或 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL / ANTHROPIC_MODEL
2. 配置文件(中间):web/backend/configs/llm.yaml
3. 内置默认值(最低)
......@@ -26,29 +22,15 @@ import json
import logging
import os
import re
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ── Provider 预设 ────────────────────────────────────────
# 各 Provider 的默认 base_url 与模型,避免每次都填
PROVIDER_PRESETS: dict[str, dict] = {
"minimax": {
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-Text-01",
},
"anthropic": {
"base_url": "", # 不填,使用官方默认
"model": "claude-sonnet-5",
},
"openai": {
"base_url": "", # 不填,使用官方默认
"model": "gpt-4o",
},
}
DEFAULT_BASE_URL = "http://192.168.40.199:8080"
DEFAULT_MODEL = "glm-5.3-flash"
# ── 配置文件路径 ─────────────────────────────────────────
......@@ -68,21 +50,20 @@ def default_config_path() -> Path:
# ── 配置 ────────────────────────────────────────────────
@dataclass
class LLMConfig:
provider: str = "minimax" # minimax / anthropic / openai
api_key: Optional[str] = None
base_url: Optional[str] = None # 留空则按 provider 预设
model: Optional[str] = None # 留空则按 provider 预设
max_tokens: int = 1024
base_url: Optional[str] = None
model: Optional[str] = None
max_tokens: int = 2048
temperature: float = 0.2
timeout: int = 30
@property
def effective_model(self) -> str:
return self.model or PROVIDER_PRESETS.get(self.provider, {}).get("model", "claude-sonnet-5")
return self.model or DEFAULT_MODEL
@property
def effective_base_url(self) -> str:
return self.base_url or PROVIDER_PRESETS.get(self.provider, {}).get("base_url", "")
return self.base_url or DEFAULT_BASE_URL
@property
def available(self) -> bool:
......@@ -93,7 +74,7 @@ class LLMConfig:
"""三段式加载:默认值 < 配置文件 < 环境变量(环境变量最高优先)
优先级说明:
- 文件配置:方便项目级持久化(推荐把 provider / model / base_url 写这里)
- 文件配置:方便项目级持久化(推荐把 api_key / model / base_url 写这里)
- 环境变量:方便临时切换 / 容器化部署 / CI
"""
path = config_path or default_config_path()
......@@ -106,25 +87,22 @@ class LLMConfig:
if v is not None and hasattr(cfg, k):
setattr(cfg, k, v)
# 2. 先把 provider 应用(再决定从哪个专用 env 拿 key)
env_provider = os.environ.get("LLM_PROVIDER")
if env_provider:
cfg.provider = env_provider
# 3. 环境变量(最高)
# 2. 环境变量(最高)
env_overrides: dict[str, Any] = {
"api_key": os.environ.get("LLM_API_KEY"),
"base_url": os.environ.get("LLM_BASE_URL"),
"model": os.environ.get("LLM_MODEL"),
}
if env_overrides["api_key"] is None:
# 兼容各 provider 的专用 env var(以更新后的 provider 为准)
if cfg.provider == "anthropic":
env_overrides["api_key"] = os.environ.get("ANTHROPIC_API_KEY")
elif cfg.provider == "openai":
env_overrides["api_key"] = os.environ.get("OPENAI_API_KEY")
elif cfg.provider == "minimax":
env_overrides["api_key"] = os.environ.get("MINIMAX_API_KEY")
env_overrides["api_key"] = (
os.environ.get("ANTHROPIC_API_KEY")
or os.environ.get("ANTHROPIC_AUTH_TOKEN")
)
if env_overrides["base_url"] is None:
env_overrides["base_url"] = os.environ.get("ANTHROPIC_BASE_URL")
if env_overrides["model"] is None:
env_overrides["model"] = os.environ.get("ANTHROPIC_MODEL")
env_max_tokens = os.environ.get("LLM_MAX_TOKENS")
if env_max_tokens:
......@@ -170,94 +148,88 @@ class LLMClient:
self._provider = None
if self.cfg.available:
try:
self._provider = self._init_provider()
self._provider = self._init_client()
logger.info(
f"LLM 客户端就绪: provider={self.cfg.provider}, "
f"model={self.cfg.effective_model}, "
f"LLM 客户端就绪: model={self.cfg.effective_model}, "
f"base_url={self.cfg.effective_base_url or '默认'}"
)
except Exception as e:
logger.warning(f"LLM 初始化失败: {e}")
else:
logger.info(
f"LLM 未配置 API Key(provider={self.cfg.provider}),"
f"如需启用请在 web/backend/configs/llm.yaml 设置 api_key 或配置环境变量"
"LLM 未配置 API Key,"
"如需启用请在 web/backend/configs/llm.yaml 设置 api_key 或配置环境变量"
)
def _init_provider(self):
"""根据 provider 创建对应 SDK 客户端"""
provider = self.cfg.provider
def _init_client(self):
"""创建 Anthropic 兼容协议客户端。"""
api_key = self.cfg.api_key
base_url = self.cfg.effective_base_url or None
if provider in ("anthropic", "minimax"):
# MiniMax 用 Anthropic SDK + 自定义 base_url
import anthropic
kwargs: dict[str, Any] = {
"api_key": api_key,
"timeout": self.cfg.timeout,
}
if base_url:
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
elif provider == "openai":
import openai
kwargs = {"api_key": api_key, "timeout": self.cfg.timeout}
if base_url:
kwargs["base_url"] = base_url
return openai.OpenAI(**kwargs)
raise ValueError(
f"不支持的 LLM provider: {provider!r}(目前支持 minimax / anthropic / openai)"
)
import anthropic
kwargs: dict[str, Any] = {
"api_key": api_key,
"timeout": self.cfg.timeout,
# 请求失败由业务接口直接反馈,避免 SDK 在后台重试导致页面长时间无响应。
"max_retries": 0,
}
if base_url:
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
@property
def available(self) -> bool:
return self._provider is not None
# ── 核心调用 ──
def complete(self, prompt: str, system: str = "", json_mode: bool = False) -> str:
def complete(
self,
prompt: str,
system: str = "",
json_mode: bool = False,
max_tokens: int | None = None,
) -> str:
"""调用 LLM,返回纯文本。失败抛 LLMUnavailable(携带可读的错误信息)。"""
if not self.available:
raise LLMUnavailable("LLM 客户端未配置 API Key")
try:
if self.cfg.provider in ("anthropic", "minimax"):
kwargs: dict[str, Any] = {
"model": self.cfg.model,
"max_tokens": self.cfg.max_tokens,
"messages": [{"role": "user", "content": prompt}],
}
# 当前 Anthropic SDK 1.x 的 Messages API 已不接收 temperature。
# MiniMax 走同一兼容接口,传入会在本地 SDK 参数校验阶段失败。
if system:
kwargs["system"] = system
msg = self._provider.messages.create(**kwargs)
return msg.content[0].text
# openai
kwargs = {
"model": self.cfg.model,
"max_tokens": self.cfg.max_tokens,
"temperature": self.cfg.temperature,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": prompt}],
base_kwargs: dict[str, Any] = {
"model": self.cfg.effective_model,
"messages": [{"role": "user", "content": prompt}],
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = self._provider.chat.completions.create(**kwargs)
return resp.choices[0].message.content
# 未支持的 provider——按理 _init_provider 已拦过,这里再防一次
raise LLMUnavailable(
f"complete() 未实现 provider={self.cfg.provider!r} 的调用分支"
# 当前 Anthropic SDK 1.x 的 Messages API 已不接收 temperature。
if system:
base_kwargs["system"] = system
# 部分兼容服务会先输出 thinking 块。第一次额度不够时,继续请求一次,
# 让模型有足够预算输出最终文本,而不是把 thinking 当作最终结果返回。
initial_budget = max_tokens or self.cfg.max_tokens
retry_budget = min(initial_budget * 2, self.cfg.max_tokens)
token_budgets = (
(initial_budget, retry_budget)
if retry_budget > initial_budget
else (initial_budget,)
)
for attempt, max_tokens in enumerate(token_budgets, start=1):
kwargs = {**base_kwargs, "max_tokens": max_tokens}
msg = self._provider.messages.create(**kwargs)
for block in msg.content:
text = getattr(block, "text", None)
if isinstance(text, str):
return text
if attempt == 1 and len(token_budgets) > 1:
logger.info(
"LLM 仅返回思考块,使用更高输出额度重试 "
f"(max_tokens={token_budgets[1]})"
)
raise LLMUnavailable("LLM 响应中没有可用的文本内容")
except LLMUnavailable:
raise
except Exception as e:
detail = _format_api_error(e, self.cfg.provider)
detail = _format_api_error(e)
logger.warning(
f"LLM 调用失败 (provider={self.cfg.provider}, "
f"model={self.cfg.effective_model}): {detail}"
f"LLM 调用失败 (model={self.cfg.effective_model}): {detail}"
)
raise LLMUnavailable(detail)
......@@ -272,16 +244,16 @@ class LLMClient:
# ── API 错误信息提取 ──────────────────────────────────────
def _format_api_error(exc: Exception, provider: str) -> str:
def _format_api_error(exc: Exception) -> str:
"""从 SDK 异常中提取可读的错误信息,供前端实时日志展示。
优先处理 Anthropic/OpenAI SDK 的结构化异常,
优先处理 Anthropic SDK 的结构化异常,
兜底处理网络/超时等通用异常。
"""
exc_type = type(exc).__name__
exc_msg = str(exc)
# ── Anthropic SDK 异常(MiniMax 兼容接口也走这里) ──
# ── Anthropic SDK 异常 ─────────────────────────────────
try:
from anthropic import (
APIStatusError,
......@@ -329,33 +301,6 @@ def _format_api_error(exc: Exception, provider: str) -> str:
except ImportError:
pass
# ── OpenAI SDK 异常 ──
try:
from openai import (
APIStatusError as OAIStatusError,
RateLimitError as OAIRateLimitError,
AuthenticationError as OAIAuthError,
APIConnectionError as OAIConnectionError,
APITimeoutError as OAITimeoutError,
)
if isinstance(exc, OAIRateLimitError):
return (
f"API Error: 请求被限流 (429) · {exc_msg}"
)
if isinstance(exc, OAIAuthError):
return (
f"API Error: 认证失败 (401) · 请检查 API Key 是否正确或已过期"
)
if isinstance(exc, OAIStatusError):
status = getattr(exc, "status_code", "?")
return f"API Error: 请求失败 ({status}) · {exc_msg}"
if isinstance(exc, OAIConnectionError):
return f"API Error: 网络连接失败 · {exc_msg}"
if isinstance(exc, OAITimeoutError):
return f"API Error: 请求超时 · {exc_msg}"
except ImportError:
pass
# ── 通用网络/超时异常 ──
import builtins
if isinstance(exc, builtins.ConnectionError):
......
......@@ -7,9 +7,8 @@ pymysql>=1.1
oracledb>=1.4
dmPython>=2.5
# LLM(web/backend/core/ai_regex.py → core/llm.py 用)
# anthropic 走 Anthropic SDK(MiniMax 也走这个,兼容接口);openai 走 OpenAI SDK
# anthropic 走 Anthropic SDK(公司内部服务兼容该接口)
# pyyaml 读 web/backend/configs/llm.yaml
anthropic>=0.30
openai>=1.30
pyyaml>=6.0
cryptography>=42.0
......@@ -20,6 +20,7 @@
from __future__ import annotations
import asyncio
import time
import re
from typing import Any, Optional
......@@ -66,10 +67,17 @@ async def ai_gen_rule(req: GenRuleRequest):
logger.info(f"POST /api/ai/rule rule_type={req.rule_type!r} desc={req.desc!r}")
started = time.monotonic()
try:
description, desc_note = gen_rule_description(req.name, req.desc, req.rule_type, req.prompt)
# 两项输出都只依赖用户的原始需求,可以并行请求,避免用户等待两次模型响应。
source_desc = (req.prompt or req.desc or req.name).strip()
description_task = asyncio.to_thread(
gen_rule_description, req.name, req.desc, req.rule_type, req.prompt
)
code_task = asyncio.to_thread(gen_rule, source_desc, req.rule_type)
(description, desc_note), (code, note) = await asyncio.gather(
description_task, code_task
)
if not description:
return GenRuleResponse(ok=False, code="", desc="", note=desc_note)
code, note = gen_rule(description, req.rule_type)
except Exception as e:
elapsed_ms = (time.monotonic() - started) * 1000
logger.exception(f"❌ AI 规则服务抛出异常(耗时 {elapsed_ms:.0f}ms)")
......
......@@ -41,7 +41,7 @@
<el-form-item label="规则名称" required><el-input v-model="form.name" placeholder="例如:身份证格式校验" /></el-form-item>
<el-form-item label="规则类型"><el-radio-group v-model="form.rule_type"><el-radio-button label="regex">正则</el-radio-button><el-radio-button label="number">数值</el-radio-button><el-radio-button label="date">日期</el-radio-button><el-radio-button label="string">字符串</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="AI生成提示词"><el-input v-model="form.ai_prompt" type="textarea" :rows="3" maxlength="2000" show-word-limit placeholder="例如:校验中国大陆居民身份证。要求为 18 位,前 17 位为数字,末位为数字或 X;同时校验行政区划码、出生日期和校验码。" /></el-form-item>
<el-form-item label="规则说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="AI 将根据提示词生成详细说明;也可以手动补充或修改" /></el-form-item>
<el-form-item label="规则说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="AI 将生成 30-40 字简短说明;也可以手动补充或修改" /></el-form-item>
<el-form-item :label="form.rule_type === 'regex' ? '正则表达式' : '校验代码'" required>
<el-input v-model="ruleContent" type="textarea" :rows="form.rule_type === 'regex' ? 2 : 8" :placeholder="form.rule_type === 'regex' ? '^.+$' : 'def check(value):\n return True'" />
<div class="expression-actions">
......@@ -98,8 +98,8 @@ async function generateByAI() {
if (!result.desc || !result.desc.trim()) throw new Error('AI 未返回规则说明,请确认后端已更新并重试')
ruleContent.value = result.code
form.description = result.desc
aiNote.value = result.note || 'AI 已根据提示词生成详细规则说明和表达式'
ElMessage.success('AI 已生成详细规则说明和表达式')
aiNote.value = result.note || 'AI 已生成简短规则说明和表达式'
ElMessage.success('AI 已生成简短规则说明和表达式')
} catch (e) { aiNote.value = e.message; ElMessage.error(`AI 生成失败:${e.message}`) } finally { aiLoading.value = false }
}
async function validateExpression() {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment