fix(ai): 统一模型传输与页面配置,公式列设计不再绕过组织设置
This commit is contained in:
+101
@@ -4,6 +4,8 @@
|
||||
API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
@@ -13,6 +15,10 @@ from .views import APIError, audit, body, endpoint
|
||||
|
||||
PROVIDERS = ("openai", "local")
|
||||
OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
POLL_ATTEMPTS = 4
|
||||
POLL_INTERVAL = 1.5
|
||||
MESSAGE_FORMAT_ERROR = "对话消息格式无效"
|
||||
MESSAGE_ORDER_ERROR = "对话消息必须按用户描述、助手追问、用户补充的顺序提交"
|
||||
|
||||
|
||||
def require_full_admin(request):
|
||||
@@ -46,6 +52,101 @@ def resolve(org):
|
||||
return None
|
||||
|
||||
|
||||
def request_response(cfg, payload, *, timeout=60.0, deadline=None):
|
||||
"""POST {base_url}/responses,并处理兼容服务异步返回的 in_progress。
|
||||
|
||||
deadline 为 time.monotonic() 的绝对时间预算;预算不足时抛 httpx.TimeoutException。
|
||||
HTTP 异常原样上抛,由调用方转换为业务错误。Authorization 仅在配置了 API Key 时发送。
|
||||
"""
|
||||
base_url = cfg["base_url"].rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}"} if cfg.get("api_key") else {}
|
||||
|
||||
def budget_kwargs():
|
||||
if deadline is None:
|
||||
return {}
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise httpx.TimeoutException("AI 请求超出时间预算")
|
||||
return {"timeout": min(timeout, remaining)}
|
||||
|
||||
def wait_before_poll():
|
||||
if deadline is not None and deadline - time.monotonic() <= POLL_INTERVAL:
|
||||
raise httpx.TimeoutException("AI 请求超出时间预算")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.post(f"{base_url}/responses", headers=headers, json=payload, **budget_kwargs())
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
if not isinstance(result, dict) or result.get("status") != "in_progress":
|
||||
break
|
||||
response_id = result.get("id")
|
||||
if not response_id:
|
||||
break
|
||||
wait_before_poll()
|
||||
polled = client.get(f"{base_url}/responses/{response_id}", headers=headers, **budget_kwargs())
|
||||
polled.raise_for_status()
|
||||
result = polled.json()
|
||||
return result
|
||||
|
||||
|
||||
def extract_output_text(result):
|
||||
"""校验响应已完成,返回首个非空 output_text(去除首尾空白);否则 ValueError。"""
|
||||
if not isinstance(result, dict) or result.get("status") != "completed":
|
||||
raise ValueError("模型响应未完成")
|
||||
output = result.get("output")
|
||||
if not isinstance(output, list):
|
||||
raise ValueError("模型响应缺少输出")
|
||||
for item in output:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("content"), list):
|
||||
continue
|
||||
for part in item["content"]:
|
||||
if not isinstance(part, dict) or part.get("type") != "output_text":
|
||||
continue
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
raise ValueError("模型响应没有文本输出")
|
||||
|
||||
|
||||
def validate_messages(
|
||||
messages,
|
||||
*,
|
||||
max_messages=12,
|
||||
user_chars=2000,
|
||||
assistant_chars=4000,
|
||||
total_chars=24000,
|
||||
order_error=MESSAGE_ORDER_ERROR,
|
||||
):
|
||||
"""统一校验对话消息:结构、长度、角色与 user/assistant 严格交替。"""
|
||||
if not isinstance(messages, list) or not 1 <= len(messages) <= max_messages:
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
normalized = []
|
||||
total = 0
|
||||
for item in messages:
|
||||
if not isinstance(item, dict) or set(item) != {"role", "content"}:
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
role, content = item["role"], item["content"]
|
||||
if role not in ("user", "assistant"):
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
if len(content) > (user_chars if role == "user" else assistant_chars):
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
total += len(content)
|
||||
normalized.append({"role": role, "content": content})
|
||||
if total > total_chars:
|
||||
raise APIError(MESSAGE_FORMAT_ERROR)
|
||||
if (
|
||||
messages[0]["role"] != "user"
|
||||
or messages[-1]["role"] != "user"
|
||||
or any(left["role"] == right["role"] for left, right in zip(messages, messages[1:]))
|
||||
):
|
||||
raise APIError(order_error)
|
||||
return normalized
|
||||
|
||||
|
||||
def org_models_endpoint(org):
|
||||
"""组织配置或环境回退的 base_url/api_key,用于探测模型列表。"""
|
||||
cfg = AIConfig.objects.filter(organization=org).first()
|
||||
|
||||
+10
-32
@@ -8,32 +8,18 @@ from django.db.models import Q
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from .ai import extract_output_text, request_response, resolve, validate_messages
|
||||
from .analysis import METRICS, context, dimensions
|
||||
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
|
||||
from .models import Dataset, FormulaDefinition, FormulaExport
|
||||
from .views import APIError, audit, body, dataset, endpoint, limit
|
||||
|
||||
|
||||
def design_column(messages, ds, ctx):
|
||||
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
|
||||
def design_column(messages, ds, ctx, cfg=None):
|
||||
cfg = cfg or resolve(ds.organization)
|
||||
if not cfg:
|
||||
raise APIError("尚未配置外部 AI 服务", 503)
|
||||
if not isinstance(messages, list) or not 1 <= len(messages) <= 12:
|
||||
raise APIError("对话消息格式无效")
|
||||
if any(
|
||||
not isinstance(item, dict)
|
||||
or set(item) != {"role", "content"}
|
||||
or item["role"] not in ("user", "assistant")
|
||||
or not isinstance(item["content"], str)
|
||||
or len(item["content"]) > 1000
|
||||
for item in messages
|
||||
):
|
||||
raise APIError("对话消息内容无效")
|
||||
if (
|
||||
messages[0]["role"] != "user"
|
||||
or messages[-1]["role"] != "user"
|
||||
or any(left["role"] == right["role"] for left, right in zip(messages, messages[1:]))
|
||||
):
|
||||
raise APIError("对话消息必须按用户描述、助手追问、用户补充的顺序提交")
|
||||
messages = validate_messages(messages)
|
||||
|
||||
available = available_metrics(ds)
|
||||
|
||||
@@ -117,9 +103,9 @@ def design_column(messages, ds, ctx):
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"model": settings.OPENAI_MODEL,
|
||||
"model": cfg["model"],
|
||||
"store": False,
|
||||
"max_output_tokens": 1400,
|
||||
"max_output_tokens": 2000,
|
||||
"input": [{"role": "system", "content": system}, *messages],
|
||||
"text": {
|
||||
"format": {
|
||||
@@ -131,17 +117,9 @@ def design_column(messages, 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}"},
|
||||
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["output"] for c in o["content"] if c["type"] == "output_text")
|
||||
text = extract_output_text(request_response(cfg, payload, timeout=30))
|
||||
if text.startswith("```"):
|
||||
text = text.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
design = json.loads(text)
|
||||
if (
|
||||
set(design) != {"complete", "questions", "column"}
|
||||
|
||||
Reference in New Issue
Block a user