feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 9s
Verify / verify (push) Failing after 1s

- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、
  /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计
- 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM),
  可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量
- 助手调用走生效配置,Copilot 文案同步更新
- 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
This commit is contained in:
Jayway Liu
2026-10-11 09:37:05 +08:00
parent 05f2ee416e
commit dd8912f7ae
13 changed files with 573 additions and 20 deletions
+149
View File
@@ -0,0 +1,149 @@
"""AI 模型配置:每个组织可在页面配置 OpenAI 或本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM 等)。
数据库配置优先;未配置时回退到环境变量(AI_ENABLED / OPENAI_*),保持旧行为兼容。
API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。
"""
import httpx
from django.conf import settings
from django.http import JsonResponse
from .models import AIConfig
from .views import APIError, audit, body, endpoint
PROVIDERS = ("openai", "local")
OPENAI_BASE_URL = "https://api.openai.com/v1"
def require_full_admin(request):
if request.member.departments:
raise APIError("没有配置 AI 模型的权限", 403)
def resolve(org):
"""返回生效的 AI 配置字典;未启用或配置不完整时返回 None。"""
cfg = AIConfig.objects.filter(organization=org).first()
if cfg:
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
key_required = cfg.provider == "openai"
if not cfg.enabled or not base_url or not cfg.model or (key_required and not cfg.api_key):
return None
return {
"enabled": True,
"provider": cfg.provider,
"base_url": base_url,
"api_key": cfg.api_key,
"model": cfg.model,
}
if settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL:
return {
"enabled": True,
"provider": "openai",
"base_url": (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"),
"api_key": settings.OPENAI_API_KEY,
"model": settings.OPENAI_MODEL,
}
return None
def org_models_endpoint(org):
"""组织配置或环境回退的 base_url/api_key,用于探测模型列表。"""
cfg = AIConfig.objects.filter(organization=org).first()
if cfg:
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
return base_url, cfg.api_key
return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY
@endpoint(("GET", "PUT"), roles=("admin",))
def ai_config(request):
require_full_admin(request)
org = request.member.organization
if request.method == "GET":
cfg = AIConfig.objects.filter(organization=org).first()
return JsonResponse(
{
"enabled": cfg.enabled if cfg else False,
"provider": cfg.provider if cfg else "local",
"base_url": cfg.base_url if cfg else "",
"model": cfg.model if cfg else "",
"api_key_set": bool(cfg and cfg.api_key),
"updated_at": cfg.updated_at.isoformat() if cfg else None,
}
)
data = body(request)
enabled = data.get("enabled")
provider = data.get("provider")
base_url = data.get("base_url", "")
api_key = data.get("api_key", "")
model = data.get("model", "")
if type(enabled) is not bool or provider not in PROVIDERS:
raise APIError("AI 配置参数无效")
if not isinstance(base_url, str) or len(base_url) > 500:
raise APIError("Base URL 无效")
base_url = base_url.rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
raise APIError("Base URL 必须以 http:// 或 https:// 开头")
if not isinstance(api_key, str) or len(api_key) > 500:
raise APIError("API Key 无效")
if not isinstance(model, str) or len(model) > 200:
raise APIError("模型名无效")
cfg = AIConfig.objects.filter(organization=org).first()
if enabled:
if provider == "local" and not base_url:
raise APIError("本地服务需要填写 Base URL,例如 http://127.0.0.1:11434/v1")
if provider == "openai" and not (api_key or (cfg and cfg.api_key)):
raise APIError("OpenAI 服务需要 API Key")
if not model:
raise APIError("启用前需要填写模型名,可点击“获取可用模型”选择")
key_changed = bool(api_key)
defaults = {
"enabled": enabled,
"provider": provider,
"base_url": base_url,
"model": model,
}
if api_key:
defaults["api_key"] = api_key
if not cfg:
AIConfig.objects.create(organization=org, **defaults)
else:
for field, value in defaults.items():
setattr(cfg, field, value)
cfg.save()
audit(
request,
"ai_config.update",
details={
"provider": provider,
"model": model,
"enabled": enabled,
"key_changed": key_changed,
},
)
return JsonResponse({"ok": True})
@endpoint(roles=("admin",))
def ai_models(request):
require_full_admin(request)
base_url, api_key = org_models_endpoint(request.member.organization)
if not base_url:
raise APIError("请先保存 Base URL 再获取模型列表", 400)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
with httpx.Client(timeout=10) as client:
response = client.get(f"{base_url}/models", headers=headers)
response.raise_for_status()
data = response.json()
items = data.get("data", [])
ids = [m.get("id") for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)]
if not ids and isinstance(data.get("models"), list):
ids = [
m.get("name")
for m in data["models"]
if isinstance(m, dict) and isinstance(m.get("name"), str)
]
return JsonResponse({"items": ids[:200]})
except (httpx.HTTPError, ValueError, AttributeError) as exc:
raise APIError("无法从该服务获取模型列表,请检查 Base URL 与 API Key", 502) from exc
+17 -13
View File
@@ -1,14 +1,17 @@
import json
import httpx
from django.conf import settings
from django.http import JsonResponse
from .ai import resolve
from .analysis import METRICS, analyze, context, dimensions
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
def model_intent(question, ds, ctx):
def model_intent(question, ds, ctx, cfg=None):
cfg = cfg or resolve(ds.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务", 503)
keys = [d["key"] for d in dimensions(ds)]
schema = {
"type": "object",
@@ -31,7 +34,7 @@ def model_intent(question, ds, ctx):
}
# Only schema, selected context and the user's question leave the server; never raw rows.
payload = {
"model": settings.OPENAI_MODEL,
"model": cfg["model"],
"store": False,
"max_output_tokens": 1200,
"input": [
@@ -55,8 +58,8 @@ def model_intent(question, ds, ctx):
try:
with httpx.Client(timeout=30) as client:
response = client.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
f"{cfg['base_url']}/responses",
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
json=payload,
)
response.raise_for_status()
@@ -107,12 +110,15 @@ def assistant(request, dataset_id):
use_model = data.get("use_model", False)
if type(use_model) is not bool:
raise APIError("AI 模式必须为布尔值")
provider = "local"
if use_model:
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
cfg = resolve(request.member.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务;请管理员在「AI 模型」中配置", 503)
if data.get("consent") is not True:
raise APIError("请先同意发送问题与当前筛选上下文", 400)
intent = model_intent(question, ds, ctx)
provider = cfg["provider"]
intent = model_intent(question, ds, ctx, cfg)
else:
action = (
"evidence"
@@ -140,21 +146,19 @@ def assistant(request, dataset_id):
else:
change = value["change"]
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.4f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.2f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
candidates = [r for r in result["rows"] if r["anomaly"]][:3]
if candidates:
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
answer += " 这是数据贡献分解,不是因果结论。"
audit(
request, "assistant.query", ds.pk, {"provider": "openai" if use_model else "local", "intent": intent}
)
audit(request, "assistant.query", ds.pk, {"provider": provider, "intent": intent})
return JsonResponse(
{
"answer": answer,
"intent": intent,
"context": result["context"],
"version": ds.sha256,
"provider": "openai" if use_model else "local",
"provider": provider,
"evidence": [
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
for r in result["rows"][:5]
+43
View File
@@ -0,0 +1,43 @@
# Generated by Django 5.2.17 on 2026-09-26 13:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0004_formuladefinition"),
]
operations = [
migrations.CreateModel(
name="AIConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("enabled", models.BooleanField(default=False)),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")],
default="local",
max_length=16,
),
),
("base_url", models.CharField(blank=True, max_length=500)),
("api_key", models.CharField(blank=True, max_length=500)),
("model", models.CharField(blank=True, max_length=200)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"organization",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="finance.organization"
),
),
],
),
]
+12
View File
@@ -92,6 +92,18 @@ class Audit(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class AIConfig(models.Model):
organization = models.OneToOneField(Organization, on_delete=models.CASCADE)
enabled = models.BooleanField(default=False)
provider = models.CharField(
max_length=16, choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")], default="local"
)
base_url = models.CharField(max_length=500, blank=True)
api_key = models.CharField(max_length=500, blank=True)
model = models.CharField(max_length=200, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Throttle(models.Model):
key = models.CharField(max_length=64, primary_key=True)
count = models.PositiveIntegerField(default=0)
+12 -2
View File
@@ -24,7 +24,7 @@ from openpyxl.cell import WriteOnlyCell
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import Audit, Dataset, Investigation, Membership, Preference, Throttle
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
class APIError(Exception):
@@ -130,10 +130,20 @@ def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
@endpoint(public=True)
def session(request):
ai_enabled = settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL)
if request.user.is_authenticated:
try:
cfg = AIConfig.objects.filter(organization=request.user.membership.organization).first()
if cfg:
ai_enabled = (
cfg.enabled and bool(cfg.model) and (cfg.provider == "local" or bool(cfg.api_key))
)
except Membership.DoesNotExist:
pass
data = {
"csrf": get_token(request),
"user": None,
"ai_enabled": settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL),
"ai_enabled": ai_enabled,
}
if request.user.is_authenticated:
try: