Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8912f7ae |
@@ -12,4 +12,5 @@ MAX_IMPORT_ROWS=1000000
|
|||||||
AI_ENABLED=0
|
AI_ENABLED=0
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=
|
OPENAI_MODEL=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
VITE_AG_GRID_LICENSE_KEY=
|
VITE_AG_GRID_LICENSE_KEY=
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' npm run test:e2e
|
|||||||
|
|
||||||
## AI 与跨平台
|
## 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 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
|
前端为标准 Web,API 无平台耦合,可由后续 PWA、Tauri 或 Capacitor 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
|
||||||
|
|
||||||
|
|||||||
+149
@@ -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
@@ -1,14 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from django.conf import settings
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from .ai import resolve
|
||||||
from .analysis import METRICS, analyze, context, dimensions
|
from .analysis import METRICS, analyze, context, dimensions
|
||||||
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
|
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)]
|
keys = [d["key"] for d in dimensions(ds)]
|
||||||
schema = {
|
schema = {
|
||||||
"type": "object",
|
"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.
|
# Only schema, selected context and the user's question leave the server; never raw rows.
|
||||||
payload = {
|
payload = {
|
||||||
"model": settings.OPENAI_MODEL,
|
"model": cfg["model"],
|
||||||
"store": False,
|
"store": False,
|
||||||
"max_output_tokens": 1200,
|
"max_output_tokens": 1200,
|
||||||
"input": [
|
"input": [
|
||||||
@@ -55,8 +58,8 @@ def model_intent(question, ds, ctx):
|
|||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=30) as client:
|
with httpx.Client(timeout=30) as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"https://api.openai.com/v1/responses",
|
f"{cfg['base_url']}/responses",
|
||||||
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
|
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -107,12 +110,15 @@ def assistant(request, dataset_id):
|
|||||||
use_model = data.get("use_model", False)
|
use_model = data.get("use_model", False)
|
||||||
if type(use_model) is not bool:
|
if type(use_model) is not bool:
|
||||||
raise APIError("AI 模式必须为布尔值")
|
raise APIError("AI 模式必须为布尔值")
|
||||||
|
provider = "local"
|
||||||
if use_model:
|
if use_model:
|
||||||
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
|
cfg = resolve(request.member.organization)
|
||||||
raise APIError("尚未配置外部 AI 服务", 503)
|
if not cfg:
|
||||||
|
raise APIError("尚未配置外部 AI 服务;请管理员在「AI 模型」中配置", 503)
|
||||||
if data.get("consent") is not True:
|
if data.get("consent") is not True:
|
||||||
raise APIError("请先同意发送问题与当前筛选上下文", 400)
|
raise APIError("请先同意发送问题与当前筛选上下文", 400)
|
||||||
intent = model_intent(question, ds, ctx)
|
provider = cfg["provider"]
|
||||||
|
intent = model_intent(question, ds, ctx, cfg)
|
||||||
else:
|
else:
|
||||||
action = (
|
action = (
|
||||||
"evidence"
|
"evidence"
|
||||||
@@ -140,21 +146,19 @@ def assistant(request, dataset_id):
|
|||||||
else:
|
else:
|
||||||
change = value["change"]
|
change = value["change"]
|
||||||
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
|
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]
|
candidates = [r for r in result["rows"] if r["anomaly"]][:3]
|
||||||
if candidates:
|
if candidates:
|
||||||
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
|
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
|
||||||
answer += " 这是数据贡献分解,不是因果结论。"
|
answer += " 这是数据贡献分解,不是因果结论。"
|
||||||
audit(
|
audit(request, "assistant.query", ds.pk, {"provider": provider, "intent": intent})
|
||||||
request, "assistant.query", ds.pk, {"provider": "openai" if use_model else "local", "intent": intent}
|
|
||||||
)
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"intent": intent,
|
"intent": intent,
|
||||||
"context": result["context"],
|
"context": result["context"],
|
||||||
"version": ds.sha256,
|
"version": ds.sha256,
|
||||||
"provider": "openai" if use_model else "local",
|
"provider": provider,
|
||||||
"evidence": [
|
"evidence": [
|
||||||
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
|
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
|
||||||
for r in result["rows"][:5]
|
for r in result["rows"][:5]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -92,6 +92,18 @@ class Audit(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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):
|
class Throttle(models.Model):
|
||||||
key = models.CharField(max_length=64, primary_key=True)
|
key = models.CharField(max_length=64, primary_key=True)
|
||||||
count = models.PositiveIntegerField(default=0)
|
count = models.PositiveIntegerField(default=0)
|
||||||
|
|||||||
+12
-2
@@ -24,7 +24,7 @@ from openpyxl.cell import WriteOnlyCell
|
|||||||
|
|
||||||
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
|
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
|
||||||
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
|
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):
|
class APIError(Exception):
|
||||||
@@ -130,10 +130,20 @@ def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
|
|||||||
|
|
||||||
@endpoint(public=True)
|
@endpoint(public=True)
|
||||||
def session(request):
|
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 = {
|
data = {
|
||||||
"csrf": get_token(request),
|
"csrf": get_token(request),
|
||||||
"user": None,
|
"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:
|
if request.user.is_authenticated:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Cpu,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -33,6 +34,7 @@ import { api, download, setCsrf } from "./api";
|
|||||||
import { Chart } from "./Chart";
|
import { Chart } from "./Chart";
|
||||||
import {
|
import {
|
||||||
AdminDialog,
|
AdminDialog,
|
||||||
|
AIConfigDialog,
|
||||||
FormulaExportDialog,
|
FormulaExportDialog,
|
||||||
Copilot,
|
Copilot,
|
||||||
EvidenceDialog,
|
EvidenceDialog,
|
||||||
@@ -83,6 +85,7 @@ export function App() {
|
|||||||
| "dictionary"
|
| "dictionary"
|
||||||
| "users"
|
| "users"
|
||||||
| "audit"
|
| "audit"
|
||||||
|
| "ai"
|
||||||
| null
|
| null
|
||||||
| "formula"
|
| "formula"
|
||||||
>(null),
|
>(null),
|
||||||
@@ -543,6 +546,10 @@ export function App() {
|
|||||||
<ShieldCheck />
|
<ShieldCheck />
|
||||||
操作审计
|
操作审计
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setModal("ai")}>
|
||||||
|
<Cpu />
|
||||||
|
AI 模型
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -1476,6 +1483,7 @@ export function App() {
|
|||||||
{(modal === "users" || modal === "audit") && (
|
{(modal === "users" || modal === "audit") && (
|
||||||
<AdminDialog mode={modal} onClose={() => setModal(null)} />
|
<AdminDialog mode={modal} onClose={() => setModal(null)} />
|
||||||
)}
|
)}
|
||||||
|
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-1
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
|
|||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import {
|
import {
|
||||||
labels,
|
labels,
|
||||||
|
type AIConfig,
|
||||||
type Context,
|
type Context,
|
||||||
type Dataset,
|
type Dataset,
|
||||||
type Metric,
|
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({
|
export function Copilot({
|
||||||
ds,
|
ds,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -550,7 +698,7 @@ export function Copilot({
|
|||||||
checked={useModel}
|
checked={useModel}
|
||||||
onChange={(e) => setUseModel(e.target.checked)}
|
onChange={(e) => setUseModel(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
使用外部 AI 理解问题
|
使用已配置的 AI 模型理解问题
|
||||||
</label>
|
</label>
|
||||||
{useModel && (
|
{useModel && (
|
||||||
<label className="check">
|
<label className="check">
|
||||||
|
|||||||
+10
-2
@@ -107,15 +107,23 @@ export interface Analysis {
|
|||||||
current: string[];
|
current: string[];
|
||||||
baseline: string[];
|
baseline: string[];
|
||||||
};
|
};
|
||||||
summary: Record<Metric, Value>;
|
summary: Record<string, Value>;
|
||||||
rows: Row[];
|
rows: Row[];
|
||||||
trend: ({ period: string } & Record<Metric, Numeric>)[];
|
trend: ({ period: string } & Record<string, Numeric>)[];
|
||||||
row_count: number;
|
row_count: number;
|
||||||
anomaly_count: number;
|
anomaly_count: number;
|
||||||
comparable: boolean;
|
comparable: boolean;
|
||||||
notice: string;
|
notice: string;
|
||||||
version: 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 {
|
export interface Session {
|
||||||
csrf: string;
|
csrf: string;
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -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))
|
MAX_IMPORT_ROWS = int(os.environ.get("MAX_IMPORT_ROWS", 1000000))
|
||||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||||
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
|
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"
|
AI_ENABLED = os.environ.get("AI_ENABLED", "0") == "1"
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from finance import formula_views, views
|
from finance import formula_views, views
|
||||||
|
from finance.ai import ai_config, ai_models
|
||||||
from finance.assistant import assistant
|
from finance.assistant import assistant
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -11,6 +12,8 @@ urlpatterns = [
|
|||||||
path("api/datasets", views.datasets),
|
path("api/datasets", views.datasets),
|
||||||
path("api/users", views.users),
|
path("api/users", views.users),
|
||||||
path("api/audit", views.audit_log),
|
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>", views.dataset_detail),
|
||||||
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
|
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
|
||||||
path("api/datasets/<uuid:dataset_id>/query", views.query),
|
path("api/datasets/<uuid:dataset_id>/query", views.query),
|
||||||
|
|||||||
+167
-1
@@ -1,17 +1,182 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
|
|
||||||
from finance.assistant import model_intent
|
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
|
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):
|
def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings):
|
||||||
ds, _, _, _, _ = workspace
|
ds, _, _, _, _ = workspace
|
||||||
|
settings.AI_ENABLED = True
|
||||||
settings.OPENAI_MODEL = "test-model"
|
settings.OPENAI_MODEL = "test-model"
|
||||||
settings.OPENAI_API_KEY = "test-key"
|
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):
|
def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings):
|
||||||
ds, *_ = workspace
|
ds, *_ = workspace
|
||||||
|
settings.AI_ENABLED = True
|
||||||
settings.OPENAI_MODEL = "test-model"
|
settings.OPENAI_MODEL = "test-model"
|
||||||
settings.OPENAI_API_KEY = "test-key"
|
settings.OPENAI_API_KEY = "test-key"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user