419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""多厂商 LLM:供应商表从 etc/llm.yaml 加载(密钥仍走环境变量)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import yaml
|
||
|
||
_DEFAULT_CONFIG_CANDIDATES = (
|
||
Path(__file__).resolve().parent / "etc" / "llm.yaml",
|
||
Path("/app/etc/llm.yaml"),
|
||
)
|
||
|
||
|
||
def _config_path() -> Path:
|
||
for key in ("AI_CONFIG_PATH", "LLM_CONFIG_PATH"):
|
||
raw = (os.getenv(key) or "").strip()
|
||
if raw:
|
||
return Path(raw)
|
||
for p in _DEFAULT_CONFIG_CANDIDATES:
|
||
if p.is_file():
|
||
return p
|
||
return _DEFAULT_CONFIG_CANDIDATES[0]
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def load_ai_config() -> dict[str, Any]:
|
||
path = _config_path()
|
||
if not path.is_file():
|
||
raise FileNotFoundError(
|
||
f"AI 配置文件不存在: {path}(可用 AI_CONFIG_PATH / LLM_CONFIG_PATH 指定)"
|
||
)
|
||
with path.open("r", encoding="utf-8") as f:
|
||
data = yaml.safe_load(f) or {}
|
||
if not isinstance(data, dict):
|
||
raise ValueError(f"AI 配置格式错误(须为 mapping): {path}")
|
||
providers = data.get("providers") or {}
|
||
if not isinstance(providers, dict) or not providers:
|
||
raise ValueError(f"AI 配置缺少 providers: {path}")
|
||
return data
|
||
|
||
|
||
def reload_ai_config() -> dict[str, Any]:
|
||
"""供热加载 / 测试清空缓存。"""
|
||
load_ai_config.cache_clear()
|
||
return load_ai_config()
|
||
|
||
|
||
def get_providers() -> dict[str, dict[str, Any]]:
|
||
raw = load_ai_config().get("providers") or {}
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for pid, meta in raw.items():
|
||
if not isinstance(meta, dict):
|
||
continue
|
||
out[str(pid).strip().lower()] = dict(meta)
|
||
return out
|
||
|
||
|
||
# 兼容旧代码:PROVIDERS 为属性式访问,始终读当前配置
|
||
class _ProvidersProxy(dict):
|
||
def _sync(self) -> None:
|
||
self.clear()
|
||
self.update(get_providers())
|
||
|
||
def __contains__(self, key: object) -> bool: # type: ignore[override]
|
||
self._sync()
|
||
return dict.__contains__(self, key)
|
||
|
||
def __getitem__(self, key: str) -> dict[str, Any]:
|
||
self._sync()
|
||
return dict.__getitem__(self, key)
|
||
|
||
def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
|
||
self._sync()
|
||
return dict.get(self, key, default)
|
||
|
||
def items(self): # type: ignore[override]
|
||
self._sync()
|
||
return dict.items(self)
|
||
|
||
def keys(self): # type: ignore[override]
|
||
self._sync()
|
||
return dict.keys(self)
|
||
|
||
def values(self): # type: ignore[override]
|
||
self._sync()
|
||
return dict.values(self)
|
||
|
||
|
||
PROVIDERS: dict[str, dict[str, Any]] = _ProvidersProxy() # type: ignore[assignment]
|
||
|
||
|
||
def _default_provider_id() -> str:
|
||
cfg = load_ai_config()
|
||
return (
|
||
(os.getenv("LLM_PROVIDER") or "").strip().lower()
|
||
or str(cfg.get("default_provider") or "deepseek").strip().lower()
|
||
or "deepseek"
|
||
)
|
||
|
||
|
||
def _aliases() -> dict[str, str]:
|
||
raw = load_ai_config().get("aliases") or {}
|
||
return {str(k).strip().lower(): str(v).strip().lower() for k, v in raw.items()}
|
||
|
||
|
||
def _vision_cfg() -> dict[str, Any]:
|
||
v = load_ai_config().get("vision") or {}
|
||
return v if isinstance(v, dict) else {}
|
||
|
||
|
||
def _normalize_pid(provider_id: str | None, *, aliases: dict[str, str] | None = None) -> str:
|
||
pid = (provider_id or "").strip().lower()
|
||
amap = aliases if aliases is not None else _aliases()
|
||
if pid in amap:
|
||
pid = amap[pid]
|
||
return pid
|
||
|
||
|
||
def list_providers() -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for pid, meta in get_providers().items():
|
||
key_env = meta.get("api_key_env") or ""
|
||
configured = True
|
||
if key_env:
|
||
configured = bool(os.getenv(str(key_env), "").strip())
|
||
out.append(
|
||
{
|
||
"id": pid,
|
||
"label": meta.get("label") or pid,
|
||
"configured": configured,
|
||
"default_model": meta.get("default_model") or "",
|
||
"models": meta.get("models") or [],
|
||
"supports_vision": bool(meta.get("supports_vision")),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def resolve_provider(provider_id: str | None, model: str | None = None) -> tuple[str, dict[str, Any], str]:
|
||
"""返回 (provider_id, meta, model_name)。"""
|
||
providers = get_providers()
|
||
pid = _normalize_pid(provider_id) or _default_provider_id()
|
||
if pid not in providers:
|
||
fallback = _default_provider_id()
|
||
pid = fallback if fallback in providers else next(iter(providers))
|
||
meta = dict(providers[pid])
|
||
# 单供应商可被环境变量覆盖 base_url(如 DASHSCOPE_BASE_URL)
|
||
env_base_key = f"{pid.upper()}_BASE_URL"
|
||
env_base = (os.getenv(env_base_key) or "").strip()
|
||
if env_base:
|
||
meta["base_url"] = env_base
|
||
elif pid == "dashscope":
|
||
# 兼容旧变量名
|
||
legacy = (os.getenv("DASHSCOPE_BASE_URL") or "").strip()
|
||
if legacy:
|
||
meta["base_url"] = legacy
|
||
vision = _vision_cfg()
|
||
if pid == (str(vision.get("provider") or "dashscope").strip().lower()):
|
||
if (os.getenv("VISION_MODEL") or "").strip() and not (model or "").strip():
|
||
meta["default_model"] = os.getenv("VISION_MODEL", "").strip()
|
||
model_name = (model or "").strip() or str(meta.get("default_model") or "")
|
||
return pid, meta, model_name
|
||
|
||
|
||
def resolve_vision_provider(
|
||
provider_id: str | None = None,
|
||
model: str | None = None,
|
||
) -> tuple[str, dict[str, Any], str]:
|
||
"""优先配置 vision / 环境变量 VISION_*;禁止把纯文本模型名传给视觉通道。"""
|
||
providers = get_providers()
|
||
vision = _vision_cfg()
|
||
vision_aliases = {
|
||
**_aliases(),
|
||
**{
|
||
str(k).strip().lower(): str(v).strip().lower()
|
||
for k, v in (vision.get("aliases") or {}).items()
|
||
},
|
||
}
|
||
env_pid = (os.getenv("VISION_PROVIDER") or "").strip().lower()
|
||
cfg_pid = str(vision.get("provider") or "").strip().lower()
|
||
vision_pid = _normalize_pid(env_pid or provider_id or cfg_pid, aliases=vision_aliases)
|
||
|
||
env_model = (os.getenv("VISION_MODEL") or "").strip()
|
||
cfg_model = str(vision.get("model") or "").strip()
|
||
passed = (model or "").strip()
|
||
hints = [str(h).lower() for h in (load_ai_config().get("vision_model_hints") or [])]
|
||
if not hints:
|
||
hints = ["vl", "vision", "qwen3", "qwen-vl", "gpt-4o", "gemini"]
|
||
vision_like = bool(
|
||
passed
|
||
and any(k in passed.lower() for k in hints)
|
||
and "deepseek" not in passed.lower()
|
||
and passed.lower() != "chat"
|
||
)
|
||
vision_model = env_model or cfg_model or (passed if vision_like else "") or ""
|
||
|
||
def _has_key(pid: str) -> bool:
|
||
meta = providers.get(pid) or {}
|
||
env = meta.get("api_key_env") or ""
|
||
return bool(env) and bool(os.getenv(str(env), "").strip())
|
||
|
||
# 文本供应商无视觉时,回退到配置的视觉供应商(若已配密钥)
|
||
if vision_pid in providers and not providers[vision_pid].get("supports_vision"):
|
||
fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
|
||
if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
|
||
vision_pid = fallback
|
||
else:
|
||
vision_pid = ""
|
||
|
||
if vision_pid and vision_pid in providers and providers[vision_pid].get("supports_vision"):
|
||
return resolve_provider(vision_pid, vision_model or None)
|
||
|
||
pid, meta, _ = resolve_provider(provider_id, None)
|
||
if meta.get("supports_vision"):
|
||
return resolve_provider(pid, vision_model or None)
|
||
|
||
fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
|
||
if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
|
||
return resolve_provider(fallback, vision_model or None)
|
||
return pid, meta, vision_model or str(meta.get("default_model") or "")
|
||
|
||
|
||
def _api_key(meta: dict[str, Any]) -> str:
|
||
env = meta.get("api_key_env") or ""
|
||
if not env:
|
||
return ""
|
||
return os.getenv(str(env), "").strip()
|
||
|
||
|
||
def enhance_blueprint_with_llm(
|
||
draft: dict[str, Any],
|
||
prompt: str,
|
||
excel_summary: str,
|
||
provider_id: str | None = None,
|
||
model: str | None = None,
|
||
) -> tuple[dict[str, Any], list[str]]:
|
||
pid, meta, model_name = resolve_provider(provider_id, model)
|
||
if pid == "heuristic":
|
||
return draft, ["使用本地启发式蓝图(未调用大模型)"]
|
||
|
||
api_key = _api_key(meta)
|
||
if not api_key:
|
||
env = meta.get("api_key_env") or ""
|
||
return draft, [f"未配置 {env},回退启发式蓝图"]
|
||
|
||
base = (meta.get("base_url") or "").rstrip("/")
|
||
system = (
|
||
"你是低代码 CMS 蓝图助手。只能输出 JSON 对象,字段必须兼容现有 draft 结构。"
|
||
"field.type 仅允许: string,text,int,bigint,decimal,boolean,date,datetime,enum,json,file_ref。"
|
||
"所有 name/slug/table/path 必须是 snake_case(小写字母数字下划线),禁止驼峰与连字符。"
|
||
"可优化 meta.name/description、field.label、pages.title;不要发明新 type,不要输出 SQL。"
|
||
"优先在给定 draft 上微调,保留 entities/fields/apis/pages 结构完整。"
|
||
"若 draft.meta.ui_preset 或页面 layout.preset 为 screenshot_faithful,必须原样保留,"
|
||
"禁止改成通用后台壳;用户未声明修改的标题/分区/筛选/图表/功能按键文案不得擅自更换。"
|
||
"若 layout.action_labels 已给出按钮原文,必须保留;actions 顺序也尽量保留。"
|
||
"页面形态须跟随用户需求与截图还原,禁止所有应用统一成同一种列表模板;"
|
||
"禁止把任意业务改写成固定行业文案(如铁路沉降),除非用户或截图本身如此。"
|
||
)
|
||
user = {
|
||
"user_prompt": prompt,
|
||
"excel_summary": excel_summary,
|
||
"draft": draft,
|
||
"instruction": "返回完整 draft JSON(不要 markdown)",
|
||
}
|
||
body: dict[str, Any] = {
|
||
"model": model_name,
|
||
"temperature": 0.2,
|
||
"messages": [
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
|
||
],
|
||
}
|
||
if meta.get("json_mode"):
|
||
body["response_format"] = {"type": "json_object"}
|
||
|
||
try:
|
||
with httpx.Client(timeout=90.0) as client:
|
||
resp = client.post(
|
||
f"{base}/chat/completions",
|
||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||
json=body,
|
||
)
|
||
resp.raise_for_status()
|
||
content = resp.json()["choices"][0]["message"]["content"]
|
||
text = content.strip()
|
||
if text.startswith("```"):
|
||
text = text.strip("`")
|
||
if text.startswith("json"):
|
||
text = text[4:].lstrip()
|
||
enhanced = json.loads(text)
|
||
if "version" in enhanced and "entities" in enhanced:
|
||
enhanced.setdefault("storage", draft.get("storage"))
|
||
enhanced.setdefault("security", draft.get("security"))
|
||
enhanced.setdefault("apis", draft.get("apis"))
|
||
return enhanced, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
|
||
if "draft" in enhanced and isinstance(enhanced["draft"], dict):
|
||
d = enhanced["draft"]
|
||
d.setdefault("storage", draft.get("storage"))
|
||
d.setdefault("security", draft.get("security"))
|
||
return d, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
|
||
return draft, ["LLM 返回结构无效,回退启发式"]
|
||
except Exception as e: # noqa: BLE001
|
||
return draft, [f"{meta.get('label', pid)} 调用失败,回退启发式: {e}"]
|
||
|
||
|
||
_VISION_PROMPT = """这些是目标业务系统的界面截图。请用中文输出「可直接写入领域说明」的结构化要点,务必逐字抄录可见文案,不要臆造。
|
||
|
||
多图规则(重要):
|
||
- 若多张图是**同一页面**的不同状态(展开/收起某一区、滚动到不同里程、筛选前后、空表/有数据),视为**一个界面**,合并描述,不要当成多个互斥布局。
|
||
- 以「信息最完整」的那张为主结构(例如展开后能看见主图+监督条+表);收起态只补充「可折叠/可滚动」交互,不要因此删掉展开态才有的分区。
|
||
- 若文案冲突,优先采用更清晰、更完整的一帧;并在备注里写「同页多状态」。
|
||
- 仅当明显是不同页面(不同顶栏系统名/不同主导航页)时,才分页面描述。
|
||
|
||
按下列小标题组织(若没有的写「未见」):
|
||
### 顶栏
|
||
- 左侧平台抬头、中间系统名、右侧链接原文
|
||
### 次级导航
|
||
- 页签原文(顿号分隔)
|
||
### 筛选条
|
||
- 工程/业务上下文原文
|
||
- 统计文案(个数类,原文照抄)
|
||
- 单选选项原文(全部 / …)
|
||
- 下拉标签原文
|
||
- 右侧提示原文(若有)
|
||
### 主图区
|
||
- 左侧竖排标签原文
|
||
- 图例系列名原文
|
||
- 图表类型与坐标大致含义
|
||
### 状态条/副图
|
||
- 左侧竖排标签原文
|
||
- 形态:单色格 / 双段条 / 其它(据实描述)
|
||
- 条上是否显示类目文字
|
||
- 是否可折叠/收起(若另一张图为收起态请注明)
|
||
### 表格区
|
||
- 左侧竖排标签、表头横标题、列名原文
|
||
### 操作按键
|
||
- 格式:操作:……、……
|
||
### 多图关系(若有多张)
|
||
- 一句话说明:同页展开/收起,或不同页
|
||
|
||
用户补充需求:{prompt}
|
||
"""
|
||
|
||
|
||
def understand_images(
|
||
prompt: str,
|
||
images: list[tuple[str, bytes, str]],
|
||
provider_id: str | None = None,
|
||
model: str | None = None,
|
||
) -> tuple[str, list[str]]:
|
||
"""返回补充描述 + warnings。无视觉能力时回退 VISION_PROVIDER / 配置中的视觉供应商。"""
|
||
warnings: list[str] = []
|
||
if not images:
|
||
return "", warnings
|
||
|
||
names = [n for n, _, _ in images]
|
||
hint = (
|
||
f"用户上传了界面截图: {', '.join(names)}。"
|
||
"默认按截图原样还原(screenshot_faithful):顶栏、页签、筛选、工具栏功能按键原文、图表分区、表格列均与原图一致;"
|
||
"仅用户文字明确要求修改的部分可调整。"
|
||
)
|
||
|
||
pid, meta, model_name = resolve_vision_provider(provider_id, model)
|
||
if not meta.get("supports_vision"):
|
||
warnings.append(
|
||
f"当前视觉通道 {pid or '无'} 不支持看图;请在 etc/llm.yaml 配置 vision.provider 并设置对应 API Key"
|
||
)
|
||
return hint, warnings
|
||
|
||
api_key = _api_key(meta)
|
||
if not api_key:
|
||
env = meta.get("api_key_env") or ""
|
||
warnings.append(f"未配置 {env},截图仅作占位提示")
|
||
return hint, warnings
|
||
|
||
base = (meta.get("base_url") or "").rstrip("/")
|
||
content: list[dict[str, Any]] = [
|
||
{"type": "text", "text": _VISION_PROMPT.format(prompt=prompt or "(无额外文字)")}
|
||
]
|
||
for name, raw, mime in images:
|
||
b64 = base64.b64encode(raw).decode("ascii")
|
||
content.append(
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {"url": f"data:{mime or 'image/png'};base64,{b64}"},
|
||
}
|
||
)
|
||
_ = name
|
||
|
||
body = {
|
||
"model": model_name,
|
||
"temperature": 0.1,
|
||
"messages": [{"role": "user", "content": content}],
|
||
}
|
||
try:
|
||
with httpx.Client(timeout=120.0) as client:
|
||
resp = client.post(
|
||
f"{base}/chat/completions",
|
||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||
json=body,
|
||
)
|
||
resp.raise_for_status()
|
||
text = (resp.json()["choices"][0]["message"]["content"] or "").strip()
|
||
if not text:
|
||
return hint, warnings + ["视觉模型返回空内容"]
|
||
return f"{hint}\n\n【截图理解】\n{text}", warnings + [
|
||
f"已用 {meta.get('label', pid)}/{model_name} 理解截图"
|
||
]
|
||
except Exception as e: # noqa: BLE001
|
||
warnings.append(f"看图失败: {e}")
|
||
return hint, warnings
|