chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
418
ai-service/llm.py
Normal file
418
ai-service/llm.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""多厂商 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
|
||||
Reference in New Issue
Block a user