1 Commits
Author SHA1 Message Date
Jayway Liu dd8912f7ae feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 9s
Verify / verify (push) Failing after 1s
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、
  /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计
- 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM),
  可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量
- 助手调用走生效配置,Copilot 文案同步更新
- 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
2026-10-11 09:37:05 +08:00
13 changed files with 573 additions and 20 deletions
+1
View File
@@ -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=
+1 -1
View File
@@ -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 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
+149
View File
@@ -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
+17 -13
View File
@@ -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]
+43
View File
@@ -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"
),
),
],
),
]
+12
View File
@@ -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)
+12 -2
View File
@@ -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:
+8
View File
@@ -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() {
<ShieldCheck />
操作审计
</button>
<button onClick={() => setModal("ai")}>
<Cpu />
AI 模型
</button>
</>
)}
</nav>
@@ -1476,6 +1483,7 @@ export function App() {
{(modal === "users" || modal === "audit") && (
<AdminDialog mode={modal} onClose={() => setModal(null)} />
)}
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
</div>
);
}
+149 -1
View File
@@ -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<AIConfig>({
enabled: false,
provider: "local",
base_url: "",
model: "",
api_key_set: false,
updated_at: null,
}),
[key, setKey] = useState(""),
[models, setModels] = useState<string[]>([]),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[listing, setListing] = useState(false);
useEffect(() => {
api<AIConfig>("/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 (
<Modal title="AI 模型配置" onClose={onClose} wide>
<div className="dialog-body">
<p className="muted">
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
</p>
<label className="check">
<input
type="checkbox"
checked={value.enabled}
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
/>
启用 AI 模型理解问题
</label>
<label>
服务商
<select
value={value.provider}
onChange={(e) =>
setValue({
...value,
provider: e.target.value as AIConfig["provider"],
})
}
>
<option value="openai">OpenAI</option>
<option value="local">
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
</option>
</select>
</label>
<label>
Base URL
<input
value={value.base_url}
placeholder={
value.provider === "local"
? "http://127.0.0.1:11434/v1"
: "https://api.openai.com/v1"
}
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
/>
</label>
<label>
API Key
<input
type="password"
value={key}
placeholder={
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label>
模型
<input
list="ai-model-list"
value={value.model}
placeholder="模型名,如 qwen2.5:7b"
onChange={(e) => setValue({ ...value, model: e.target.value })}
/>
<datalist id="ai-model-list">
{models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</label>
<footer>
<button onClick={onClose}>取消</button>
<button
type="button"
disabled={listing || busy}
onClick={fetchModels}
>
{listing ? "获取中…" : "获取可用模型"}
</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存配置"}
</button>
</footer>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<p className="muted">
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
配置修改会记入操作审计。
</p>
</div>
</Modal>
);
}
export function Copilot({
ds,
ctx,
@@ -550,7 +698,7 @@ export function Copilot({
checked={useModel}
onChange={(e) => setUseModel(e.target.checked)}
/>
使用外部 AI 理解问题
使用已配置的 AI 模型理解问题
</label>
{useModel && (
<label className="check">
+10 -2
View File
@@ -107,15 +107,23 @@ export interface Analysis {
current: string[];
baseline: string[];
};
summary: Record<Metric, Value>;
summary: Record<string, Value>;
rows: Row[];
trend: ({ period: string } & Record<Metric, Numeric>)[];
trend: ({ period: string } & Record<string, Numeric>)[];
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: {
+1
View File
@@ -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,
+3
View File
@@ -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/<uuid:dataset_id>", views.dataset_detail),
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
path("api/datasets/<uuid:dataset_id>/query", views.query),
+167 -1
View File
@@ -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"