From dd8912f7aea5d85d3e6c6f74751b9bbfa95633a7 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sun, 11 Oct 2026 09:37:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E9=A1=B5=E9=9D=A2=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BA=91=E7=AB=AF=E6=88=96=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20OpenAI=20=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、 /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计 - 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM), 可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量 - 助手调用走生效配置,Copilot 文案同步更新 - 全权限管理员可在「管理 → AI 模型」配置;补接口级测试 --- .env.example | 1 + README.md | 2 +- finance/ai.py | 149 ++++++++++++++++++++++++ finance/assistant.py | 30 ++--- finance/migrations/0005_aiconfig.py | 43 +++++++ finance/models.py | 12 ++ finance/views.py | 14 ++- frontend/src/App.tsx | 8 ++ frontend/src/dialogs.tsx | 150 ++++++++++++++++++++++++- frontend/src/types.ts | 12 +- server/settings.py | 1 + server/urls.py | 3 + tests/test_assistant_api.py | 168 +++++++++++++++++++++++++++- 13 files changed, 573 insertions(+), 20 deletions(-) create mode 100644 finance/ai.py create mode 100644 finance/migrations/0005_aiconfig.py diff --git a/.env.example b/.env.example index cebb790..4d77942 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,5 @@ MAX_IMPORT_ROWS=1000000 AI_ENABLED=0 OPENAI_API_KEY= OPENAI_MODEL= +OPENAI_BASE_URL=https://api.openai.com/v1 VITE_AG_GRID_LICENSE_KEY= diff --git a/README.md b/README.md index 25e1afa..7cc9f91 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' npm run test:e2e ## AI 与跨平台 -默认 AI_ENABLED=0。启用外部模型需配置 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`,并完成机构数据处理审批。密钥只在后端。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。 +默认 AI_ENABLED=0。启用外部模型有两种方式,数据库配置优先:全权限管理员在页面「管理 → AI 模型」配置服务商(OpenAI 或本地 OpenAI 兼容服务如 Ollama / LM Studio / vLLM)、Base URL、API Key 与模型,可从 `/v1/models` 一键获取本地服务模型列表多选一;或配置环境变量 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`(可选 `OPENAI_BASE_URL`)。均需完成机构数据处理审批。密钥只在后端:读取接口不回传密钥原文,配置修改记入审计(不含密钥)。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。 前端为标准 Web,API 无平台耦合,可由后续 PWA、Tauri 或 Capacitor 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。 diff --git a/finance/ai.py b/finance/ai.py new file mode 100644 index 0000000..76c6b4d --- /dev/null +++ b/finance/ai.py @@ -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 diff --git a/finance/assistant.py b/finance/assistant.py index fdeb134..d69f5b1 100644 --- a/finance/assistant.py +++ b/finance/assistant.py @@ -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] diff --git a/finance/migrations/0005_aiconfig.py b/finance/migrations/0005_aiconfig.py new file mode 100644 index 0000000..4c41f23 --- /dev/null +++ b/finance/migrations/0005_aiconfig.py @@ -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" + ), + ), + ], + ), + ] diff --git a/finance/models.py b/finance/models.py index 1de5920..84a0a1e 100644 --- a/finance/models.py +++ b/finance/models.py @@ -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) diff --git a/finance/views.py b/finance/views.py index 4f78477..8034905 100644 --- a/finance/views.py +++ b/finance/views.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd41397..d4b2b01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { Bell, BookOpen, ChevronRight, + Cpu, Database, Download, Eye, @@ -33,6 +34,7 @@ import { api, download, setCsrf } from "./api"; import { Chart } from "./Chart"; import { AdminDialog, + AIConfigDialog, FormulaExportDialog, Copilot, EvidenceDialog, @@ -83,6 +85,7 @@ export function App() { | "dictionary" | "users" | "audit" + | "ai" | null | "formula" >(null), @@ -543,6 +546,10 @@ export function App() { 操作审计 + )} @@ -1476,6 +1483,7 @@ export function App() { {(modal === "users" || modal === "audit") && ( setModal(null)} /> )} + {modal === "ai" && setModal(null)} />} ); } diff --git a/frontend/src/dialogs.tsx b/frontend/src/dialogs.tsx index 0e52997..56dc511 100644 --- a/frontend/src/dialogs.tsx +++ b/frontend/src/dialogs.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea import { api } from "./api"; import { labels, + type AIConfig, type Context, type Dataset, type Metric, @@ -471,6 +472,153 @@ export function EvidenceDialog({ ); } +export function AIConfigDialog({ onClose }: { onClose: () => void }) { + const [value, setValue] = useState({ + enabled: false, + provider: "local", + base_url: "", + model: "", + api_key_set: false, + updated_at: null, + }), + [key, setKey] = useState(""), + [models, setModels] = useState([]), + [error, setError] = useState(""), + [busy, setBusy] = useState(false), + [listing, setListing] = useState(false); + useEffect(() => { + api("/ai-config") + .then((r) => setValue(r)) + .catch((e) => setError((e as Error).message)); + }, []); + async function fetchModels() { + setListing(true); + setError(""); + try { + const r = await api<{ items: string[] }>("/ai-models"); + setModels(r.items); + if (!r.items.length) setError("该服务没有返回可用模型"); + } catch (e) { + setError((e as Error).message); + } finally { + setListing(false); + } + } + async function save() { + setBusy(true); + setError(""); + try { + await api("/ai-config", "PUT", { + enabled: value.enabled, + provider: value.provider, + base_url: value.base_url, + model: value.model, + api_key: key, + }); + setKey(""); + setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) })); + if (value.base_url) await fetchModels(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + return ( + +
+

+ 配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。 +

+ + + + + +
+ + + +
+ {error && ( +

+ {error} +

+ )} +

+ 只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端, + 配置修改会记入操作审计。 +

+
+
+ ); +} + export function Copilot({ ds, ctx, @@ -550,7 +698,7 @@ export function Copilot({ checked={useModel} onChange={(e) => setUseModel(e.target.checked)} /> - 使用外部 AI 理解问题 + 使用已配置的 AI 模型理解问题 {useModel && (