Files
ai_site/ai-service/llm.py
2026-07-31 10:31:17 +08:00

419 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多厂商 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