- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、 /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计 - 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM), 可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量 - 助手调用走生效配置,Copilot 文案同步更新 - 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
import json
|
|
|
|
import httpx
|
|
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, 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",
|
|
"properties": {
|
|
"action": {"type": "string", "enum": ["overview", "anomalies", "drill", "evidence"]},
|
|
"metric": {"type": "string", "enum": list(METRICS)},
|
|
"group": {"type": "string", "enum": keys},
|
|
"filters": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"key": {"type": "string", "enum": keys}, "value": {"type": "string"}},
|
|
"required": ["key", "value"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
"required": ["action", "metric", "group", "filters"],
|
|
"additionalProperties": False,
|
|
}
|
|
# Only schema, selected context and the user's question leave the server; never raw rows.
|
|
payload = {
|
|
"model": cfg["model"],
|
|
"store": False,
|
|
"max_output_tokens": 1200,
|
|
"input": [
|
|
{
|
|
"role": "system",
|
|
"content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。保留当前筛选,除非用户明确改变。"
|
|
+ json.dumps(
|
|
{
|
|
"dimensions": dimensions(ds),
|
|
"context": {k: ctx[k] for k in ("metric", "group", "filters")},
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
},
|
|
{"role": "user", "content": question},
|
|
],
|
|
"text": {
|
|
"format": {"type": "json_schema", "name": "finance_intent", "strict": True, "schema": schema}
|
|
},
|
|
}
|
|
try:
|
|
with httpx.Client(timeout=30) as client:
|
|
response = client.post(
|
|
f"{cfg['base_url']}/responses",
|
|
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
|
|
json=payload,
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if not isinstance(result, dict) or result.get("status") != "completed":
|
|
raise ValueError("incomplete response")
|
|
text = next(
|
|
c["text"]
|
|
for o in result.get("output", [])
|
|
for c in o.get("content", [])
|
|
if c.get("type") == "output_text"
|
|
)
|
|
intent = json.loads(text)
|
|
if not isinstance(intent, dict) or set(intent) != {"action", "metric", "group", "filters"}:
|
|
raise ValueError("invalid intent")
|
|
if intent.get("action") not in ("overview", "anomalies", "drill", "evidence"):
|
|
raise ValueError("invalid action")
|
|
filters = intent.pop("filters")
|
|
if not isinstance(filters, list) or len(filters) > 20:
|
|
raise ValueError("invalid filters")
|
|
if any(
|
|
not isinstance(f, dict)
|
|
or set(f) != {"key", "value"}
|
|
or not isinstance(f["key"], str)
|
|
or not isinstance(f["value"], str)
|
|
for f in filters
|
|
):
|
|
raise ValueError("invalid filter")
|
|
intent["filters"] = {f["key"]: f["value"] for f in filters}
|
|
if len(intent["filters"]) != len(filters):
|
|
raise ValueError("duplicate filter")
|
|
context(ds, {**ctx, **intent})
|
|
return intent
|
|
except (httpx.HTTPError, ValueError, KeyError, TypeError, AttributeError, StopIteration) as exc:
|
|
raise APIError("模型暂时不可用或返回了无效意图;未执行任何操作", 502) from exc
|
|
|
|
|
|
@endpoint(("POST",))
|
|
def assistant(request, dataset_id):
|
|
ds = dataset(request, dataset_id, True)
|
|
data = body(request)
|
|
question = data.get("question", "")
|
|
if not isinstance(question, str) or not 1 <= len(question.strip()) <= 2000:
|
|
raise APIError("问题需为 1 至 2,000 字")
|
|
limit(f"assistant:{request.user.pk}", 20, 60)
|
|
ctx = context(ds, data.get("context", {}))
|
|
pref, _ = preferences(request, ds)
|
|
use_model = data.get("use_model", False)
|
|
if type(use_model) is not bool:
|
|
raise APIError("AI 模式必须为布尔值")
|
|
provider = "local"
|
|
if use_model:
|
|
cfg = resolve(request.member.organization)
|
|
if not cfg:
|
|
raise APIError("尚未配置外部 AI 服务;请管理员在「AI 模型」中配置", 503)
|
|
if data.get("consent") is not True:
|
|
raise APIError("请先同意发送问题与当前筛选上下文", 400)
|
|
provider = cfg["provider"]
|
|
intent = model_intent(question, ds, ctx, cfg)
|
|
else:
|
|
action = (
|
|
"evidence"
|
|
if "明细" in question or "证据" in question
|
|
else "anomalies"
|
|
if "异常" in question
|
|
else "drill"
|
|
if "下钻" in question
|
|
else "overview"
|
|
)
|
|
intent = {"action": action, "metric": ctx["metric"], "group": ctx["group"], "filters": ctx["filters"]}
|
|
for k, label in METRICS.items():
|
|
if label in question:
|
|
intent["metric"] = k
|
|
for d in dimensions(ds):
|
|
if d["name"] in question:
|
|
intent["group"] = d["key"]
|
|
result = analyze(ds, request.member, {**ctx, **intent}, pref)
|
|
metric = intent["metric"]
|
|
value = result["summary"][metric]
|
|
if not result["comparable"]:
|
|
answer = "缺少期间 " + "、".join(result["context"]["missing"]) + ",不能给出比较或异常结论。"
|
|
elif value["current"] is None:
|
|
answer = f"当前数据缺少计算{METRICS[metric]}所需的完整字段。"
|
|
else:
|
|
change = value["change"]
|
|
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
|
|
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": provider, "intent": intent})
|
|
return JsonResponse(
|
|
{
|
|
"answer": answer,
|
|
"intent": intent,
|
|
"context": result["context"],
|
|
"version": ds.sha256,
|
|
"provider": provider,
|
|
"evidence": [
|
|
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
|
|
for r in result["rows"][:5]
|
|
],
|
|
}
|
|
)
|