feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、 /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计 - 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM), 可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量 - 助手调用走生效配置,Copilot 文案同步更新 - 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
This commit is contained in:
+149
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user