- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、 /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计 - 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM), 可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量 - 助手调用走生效配置,Copilot 文案同步更新 - 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
388 lines
14 KiB
Python
388 lines
14 KiB
Python
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 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"
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": json.dumps(
|
|
{
|
|
"action": "overview",
|
|
"metric": "revenue",
|
|
"group": "department",
|
|
"filters": [{"key": "department", "value": "儿科"}],
|
|
}
|
|
),
|
|
}
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
with patch("finance.assistant.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = Response()
|
|
intent = model_intent("看儿科收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
|
|
|
|
assert intent == {
|
|
"action": "overview",
|
|
"metric": "revenue",
|
|
"group": "department",
|
|
"filters": {"department": "儿科"},
|
|
}
|
|
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
assert payload["store"] is False
|
|
assert "C1" not in json.dumps(payload)
|
|
|
|
|
|
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"
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": '{"action":"overview","metric":"revenue","group":"department","filters":[{"key":"__class__","value":"x"}]}',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
response = Response()
|
|
with patch("finance.assistant.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = response
|
|
with pytest.raises(Exception, match="无效意图"):
|
|
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
|
|
|
|
with patch("finance.assistant.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = type(
|
|
"Response",
|
|
(),
|
|
{"raise_for_status": lambda self: None, "json": lambda self: {"status": "in_progress"}},
|
|
)()
|
|
with pytest.raises(Exception, match="无效意图"):
|
|
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
|
|
|
|
|
|
def test_assistant_local_response_and_external_consent_guard(workspace, settings):
|
|
ds, _, client, *_ = workspace
|
|
url = f"/api/datasets/{ds.pk}/assistant"
|
|
payload = {
|
|
"question": "查看实际收入",
|
|
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
|
}
|
|
response = client.post(url, data=payload, content_type="application/json")
|
|
assert response.status_code == 200
|
|
assert response.json()["provider"] == "local"
|
|
assert Audit.objects.filter(action="assistant.query").exists()
|
|
|
|
settings.AI_ENABLED = True
|
|
settings.OPENAI_API_KEY = "test-key"
|
|
settings.OPENAI_MODEL = "test-model"
|
|
denied = client.post(
|
|
url,
|
|
data={**payload, "use_model": True, "consent": False},
|
|
content_type="application/json",
|
|
)
|
|
assert denied.status_code == 400
|
|
assert "同意" in denied.json()["error"]
|
|
|
|
|
|
def test_formula_design_and_export_api_validate_consent_and_scope(workspace, settings):
|
|
ds, _, client, *_ = workspace
|
|
url = f"/api/datasets/{ds.pk}"
|
|
payload = {
|
|
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
|
"messages": [{"role": "user", "content": "收入成本差"}],
|
|
"consent": False,
|
|
}
|
|
denied = client.post(f"{url}/formula-design", data=payload, content_type="application/json")
|
|
assert denied.status_code == 400
|
|
|
|
export = client.post(
|
|
f"{url}/formula-exports",
|
|
data={**payload["context"], "custom_columns": [{"expression": {}}]},
|
|
content_type="application/json",
|
|
)
|
|
assert export.status_code == 400
|
|
assert FormulaExport.objects.count() == 0
|
|
|
|
scoped = Client()
|
|
user = ds.created_by
|
|
user.membership.departments = ["儿科"]
|
|
user.membership.save(update_fields=["departments"])
|
|
assert scoped.login(username=user.username, password="Finance-TestPassword991!")
|
|
forbidden = scoped.post(
|
|
f"{url}/formula-exports", data=payload["context"], content_type="application/json"
|
|
)
|
|
assert forbidden.status_code == 403
|
|
assert scoped.get(f"{url}/formula-definitions").status_code == 403
|
|
|
|
|
|
def test_formula_definition_library_is_private_validated_and_audited(workspace):
|
|
ds, _, client, _, _ = workspace
|
|
url = f"/api/datasets/{ds.pk}/formula-definitions"
|
|
definition = {
|
|
"name": "收入成本差",
|
|
"meaning": "实际收入减成本",
|
|
"scope": "所选分组与期间",
|
|
"missing": "结果不可用(#N/A);零分母不可用",
|
|
"expression": {
|
|
"operator": "subtract",
|
|
"left": {"metric": "revenue"},
|
|
"right": {"metric": "cost"},
|
|
},
|
|
}
|
|
saved = client.post(url, data={"definition": definition}, content_type="application/json")
|
|
assert saved.status_code == 201
|
|
definition_id = saved.json()["id"]
|
|
assert client.get(url).json()["items"][0]["definition"] == definition
|
|
|
|
updated = client.post(
|
|
url,
|
|
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
|
|
content_type="application/json",
|
|
)
|
|
assert updated.status_code == 200
|
|
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
|
|
assert updated.json()["definition"]["meaning"] == "更新说明"
|
|
|
|
other = Client()
|
|
other_user = ds.created_by.__class__.objects.create_user(
|
|
"formula-library-other", password="Formula-Library-TestPassword991!"
|
|
)
|
|
Membership.objects.create(user=other_user, organization=ds.organization, role="analyst")
|
|
assert other.login(username=other_user.username, password="Formula-Library-TestPassword991!")
|
|
assert other.get(url).json()["items"] == []
|
|
assert other.delete(f"{url}/{definition_id}").status_code == 404
|
|
|
|
deleted = client.delete(f"{url}/{definition_id}")
|
|
assert deleted.status_code == 200
|
|
assert not FormulaDefinition.objects.filter(pk=definition_id).exists()
|
|
assert Audit.objects.filter(action="formula_definition.save").count() == 2
|
|
assert Audit.objects.filter(action="formula_definition.delete").count() == 1
|
|
|
|
|
|
def test_formula_definition_library_rejects_unavailable_metric(workspace):
|
|
ds, _, client, *_ = workspace
|
|
mapping = {**ds.mapping, "columns": [c for c in ds.mapping["columns"] if c["role"] != "cost"]}
|
|
ds.mapping = mapping
|
|
ds.save(update_fields=["mapping"])
|
|
response = client.post(
|
|
f"/api/datasets/{ds.pk}/formula-definitions",
|
|
data={
|
|
"definition": {
|
|
"name": "收入成本差",
|
|
"meaning": "实际收入减成本",
|
|
"scope": "所选分组与期间",
|
|
"missing": "结果不可用(#N/A);零分母不可用",
|
|
"expression": {
|
|
"operator": "subtract",
|
|
"left": {"metric": "revenue"},
|
|
"right": {"metric": "cost"},
|
|
},
|
|
}
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 400
|
|
assert "成本" in response.json()["error"]
|