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:
+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]
|
||||
|
||||
Reference in New Issue
Block a user