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() { 操作审计 + setModal("ai")}> + + AI 模型 + > )} @@ -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 ( + + + + 配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。 + + + setValue({ ...value, enabled: e.target.checked })} + /> + 启用 AI 模型理解问题 + + + 服务商 + + setValue({ + ...value, + provider: e.target.value as AIConfig["provider"], + }) + } + > + OpenAI + + 本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM) + + + + + Base URL + setValue({ ...value, base_url: e.target.value })} + /> + + + API Key + setKey(e.target.value)} + /> + + + 模型 + setValue({ ...value, model: e.target.value })} + /> + + {models.map((m) => ( + + ))} + + + + {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 && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 79cfa75..773d36e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -107,15 +107,23 @@ export interface Analysis { current: string[]; baseline: string[]; }; - summary: Record; + summary: Record; rows: Row[]; - trend: ({ period: string } & Record)[]; + trend: ({ period: string } & Record)[]; row_count: number; anomaly_count: number; comparable: boolean; notice: string; version: string; } +export interface AIConfig { + enabled: boolean; + provider: "openai" | "local"; + base_url: string; + model: string; + api_key_set: boolean; + updated_at: string | null; +} export interface Session { csrf: string; user: { diff --git a/server/settings.py b/server/settings.py index b543b8a..c92494d 100644 --- a/server/settings.py +++ b/server/settings.py @@ -93,6 +93,7 @@ MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 64 * 1024 * 1024)) MAX_IMPORT_ROWS = int(os.environ.get("MAX_IMPORT_ROWS", 1000000)) OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "") +OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") AI_ENABLED = os.environ.get("AI_ENABLED", "0") == "1" LOGGING = { "version": 1, diff --git a/server/urls.py b/server/urls.py index 9760687..a143ce9 100644 --- a/server/urls.py +++ b/server/urls.py @@ -1,6 +1,7 @@ from django.urls import path from finance import formula_views, views +from finance.ai import ai_config, ai_models from finance.assistant import assistant urlpatterns = [ @@ -11,6 +12,8 @@ urlpatterns = [ path("api/datasets", views.datasets), path("api/users", views.users), path("api/audit", views.audit_log), + path("api/ai-config", ai_config), + path("api/ai-models", ai_models), path("api/datasets/", views.dataset_detail), path("api/datasets//mapping", views.mapping), path("api/datasets//query", views.query), diff --git a/tests/test_assistant_api.py b/tests/test_assistant_api.py index 62d9895..14856af 100644 --- a/tests/test_assistant_api.py +++ b/tests/test_assistant_api.py @@ -1,17 +1,182 @@ import json from unittest.mock import patch +import httpx import pytest +from django.contrib.auth.models import User from django.test import Client from finance.assistant import model_intent -from finance.models import Audit, FormulaDefinition, FormulaExport, Membership +from finance.models import AIConfig, Audit, FormulaDefinition, FormulaExport, Membership pytestmark = pytest.mark.django_db +def completed_response(intent=None): + class Response: + def raise_for_status(self): + return None + + def json(self): + return { + "status": "completed", + "output": [ + { + "content": [ + { + "type": "output_text", + "text": json.dumps( + intent + or { + "action": "overview", + "metric": "revenue", + "group": "department", + "filters": [{"key": "department", "value": "儿科"}], + } + ), + } + ] + } + ], + } + + return Response() + + +def test_ai_config_requires_full_admin_and_masks_key(workspace): + ds, member, client, _, _ = workspace + url = "/api/ai-config" + + viewer = User.objects.create_user("ai-viewer", password="Finance-TestPassword991!") + Membership.objects.create(user=viewer, organization=ds.organization, role="viewer") + viewer_client = Client() + assert viewer_client.login(username="ai-viewer", password="Finance-TestPassword991!") + assert viewer_client.get(url).status_code == 403 + + member.departments = ["儿科"] + member.save(update_fields=["departments"]) + scoped = Client() + assert scoped.login(username=member.user.username, password="Finance-TestPassword991!") + assert scoped.get(url).status_code == 403 + member.departments = [] + member.save(update_fields=["departments"]) + + assert client.get(url).json() == { + "enabled": False, + "provider": "local", + "base_url": "", + "model": "", + "api_key_set": False, + "updated_at": None, + } + + invalid = [ + {"enabled": True, "provider": "local", "base_url": "", "model": "qwen2.5:7b"}, + {"enabled": True, "provider": "openai", "base_url": "", "model": "gpt-4o"}, + {"enabled": True, "provider": "local", "base_url": "ftp://x", "model": "m"}, + {"enabled": True, "provider": "local", "base_url": "http://x/v1", "model": ""}, + ] + for payload in invalid: + assert client.put(url, data=payload, content_type="application/json").status_code == 400 + + saved = client.put( + url, + data={ + "enabled": True, + "provider": "local", + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "ollama-secret", + "model": "qwen2.5:7b", + }, + content_type="application/json", + ) + assert saved.status_code == 200 + shown = client.get(url).json() + assert shown["api_key_set"] is True + assert shown["enabled"] is True and shown["provider"] == "local" + assert "ollama-secret" not in json.dumps(shown) + assert client.get("/api/session").json()["ai_enabled"] is True + assert Audit.objects.filter(action="ai_config.update").exists() + + +def test_ai_models_lists_local_models(workspace): + ds, _, client, _, _ = workspace + client.put( + "/api/ai-config", + data={ + "enabled": False, + "provider": "local", + "base_url": "http://127.0.0.1:11434/v1", + "model": "", + }, + content_type="application/json", + ) + with patch("finance.ai.httpx.Client") as http: + http.return_value.__enter__.return_value.get.return_value = type( + "Response", + (), + { + "raise_for_status": lambda self: None, + "json": lambda self: {"data": [{"id": "qwen2.5:7b"}, {"id": "llama3.1:8b"}]}, + }, + )() + listed = client.get("/api/ai-models") + assert listed.status_code == 200 + assert listed.json()["items"] == ["qwen2.5:7b", "llama3.1:8b"] + call = http.return_value.__enter__.return_value.get.call_args + assert call.args[0] == "http://127.0.0.1:11434/v1/models" + + with patch("finance.ai.httpx.Client") as http: + http.return_value.__enter__.return_value.get.side_effect = httpx.ConnectError("boom") + failed = client.get("/api/ai-models") + assert failed.status_code == 502 + + +def test_assistant_uses_org_db_config(workspace): + ds, _, client, *_ = workspace + client.put( + "/api/ai-config", + data={ + "enabled": True, + "provider": "local", + "base_url": "http://127.0.0.1:11434/v1", + "model": "qwen2.5:7b", + }, + content_type="application/json", + ) + with patch("finance.assistant.httpx.Client") as http: + http.return_value.__enter__.return_value.post.return_value = completed_response() + response = client.post( + f"/api/datasets/{ds.pk}/assistant", + data={ + "question": "分析儿科收入", + "context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}, + "use_model": True, + "consent": True, + }, + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json()["provider"] == "local" + call = http.return_value.__enter__.return_value.post.call_args + assert call.args[0] == "http://127.0.0.1:11434/v1/responses" + assert call.kwargs["json"]["model"] == "qwen2.5:7b" + assert "Authorization" not in call.kwargs["headers"] + + disabled = AIConfig.objects.get(organization=ds.organization) + disabled.enabled = False + disabled.save(update_fields=["enabled"]) + denied = client.post( + f"/api/datasets/{ds.pk}/assistant", + data={"question": "分析儿科收入", "use_model": True, "consent": True}, + content_type="application/json", + ) + assert denied.status_code == 503 + + def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings): ds, _, _, _, _ = workspace + settings.AI_ENABLED = True settings.OPENAI_MODEL = "test-model" settings.OPENAI_API_KEY = "test-key" @@ -58,6 +223,7 @@ def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings): ds, *_ = workspace + settings.AI_ENABLED = True settings.OPENAI_MODEL = "test-model" settings.OPENAI_API_KEY = "test-key"
+ 配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。 +
+ {error} +
+ 只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端, + 配置修改会记入操作审计。 +