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:
+167
-1
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user