Commit bd79c9e5 authored by wangteng's avatar wangteng

模型更换

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