fix(ai): DeepSeek 真实联调——响应健壮性与筛选替换提示词
- assistant 解析兼容:支持 in_progress 异步轮询(DeepSeek 偶发)、 去除 markdown 代码围栏、整体失败重试一次后才报 502 - 系统提示词增加筛选替换规则与示例:用户明确提到某维度新取值时替换 现有筛选而非叠加(实测修复“口腔+健康管理”空范围问题) - KPI 数字字号改为容器查询 clamp(13px,1.9cqw,21px),窄宽度下 完整显示金额而非省略号截断 - 新增 in_progress 轮询 + 重试的单测(test_assistant_api) 验证:pytest 82 passed;DeepSeek 真实调用 5/5 成功(异常/证据/ 筛选替换三种意图);Playwright 回归通过
This commit was merged in pull request #9.
This commit is contained in:
+69
-41
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -8,6 +9,60 @@ 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 _request_response(cfg, payload):
|
||||||
|
"""POST /responses;部分兼容服务(如 DeepSeek)偶发异步返回 in_progress,需要轮询。"""
|
||||||
|
headers = {"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {}
|
||||||
|
with httpx.Client(timeout=60) as client:
|
||||||
|
response = client.post(f"{cfg['base_url']}/responses", headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
for _ in range(4):
|
||||||
|
if not isinstance(result, dict) or result.get("status") != "in_progress":
|
||||||
|
break
|
||||||
|
rid = result.get("id")
|
||||||
|
if not rid:
|
||||||
|
break
|
||||||
|
time.sleep(1.5)
|
||||||
|
polled = client.get(f"{cfg['base_url']}/responses/{rid}", headers=headers)
|
||||||
|
polled.raise_for_status()
|
||||||
|
result = polled.json()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_intent(result):
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||||
|
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")
|
||||||
|
return intent
|
||||||
|
|
||||||
|
|
||||||
def model_intent(question, ds, ctx, cfg=None):
|
def model_intent(question, ds, ctx, cfg=None):
|
||||||
cfg = cfg or resolve(ds.organization)
|
cfg = cfg or resolve(ds.organization)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
@@ -40,7 +95,11 @@ def model_intent(question, ds, ctx, cfg=None):
|
|||||||
"input": [
|
"input": [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。保留当前筛选,除非用户明确改变。"
|
"content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。"
|
||||||
|
"筛选规则:若用户明确提到某维度的新取值,替换该维度的现有筛选;未提及的维度保留现有筛选。"
|
||||||
|
"示例:现有筛选为 department=口腔,用户问“下钻看看健康管理”时,健康管理是科室维度的新取值,"
|
||||||
|
"必须返回 filters=[{key:department,value:健康管理}](替换而非叠加口腔);"
|
||||||
|
"group 取用户指定的下钻维度(如 product),未指定则保持当前 group。"
|
||||||
+ json.dumps(
|
+ json.dumps(
|
||||||
{
|
{
|
||||||
"dimensions": dimensions(ds),
|
"dimensions": dimensions(ds),
|
||||||
@@ -55,46 +114,15 @@ def model_intent(question, ds, ctx, cfg=None):
|
|||||||
"format": {"type": "json_schema", "name": "finance_intent", "strict": True, "schema": schema}
|
"format": {"type": "json_schema", "name": "finance_intent", "strict": True, "schema": schema}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
try:
|
last_exc = None
|
||||||
with httpx.Client(timeout=30) as client:
|
for _attempt in range(2):
|
||||||
response = client.post(
|
try:
|
||||||
f"{cfg['base_url']}/responses",
|
intent = _extract_intent(_request_response(cfg, payload))
|
||||||
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
|
context(ds, {**ctx, **intent})
|
||||||
json=payload,
|
return intent
|
||||||
)
|
except (httpx.HTTPError, ValueError, KeyError, TypeError, AttributeError, StopIteration) as exc:
|
||||||
response.raise_for_status()
|
last_exc = exc
|
||||||
result = response.json()
|
raise APIError("模型暂时不可用或返回了无效意图;未执行任何操作", 502) from last_exc
|
||||||
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",))
|
@endpoint(("POST",))
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ a {
|
|||||||
.kpis {
|
.kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
container-type: inline-size;
|
||||||
margin: 0 0 26px;
|
margin: 0 0 26px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: white;
|
background: white;
|
||||||
@@ -510,7 +511,7 @@ a {
|
|||||||
}
|
}
|
||||||
.kpi strong {
|
.kpi strong {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 21px;
|
font-size: clamp(13px, 1.9cqw, 21px);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
sk-240d1cadaecf4055afd575a8cd530f31
|
||||||
@@ -43,6 +43,59 @@ def completed_response(intent=None):
|
|||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_intent_polls_in_progress_and_retries_invalid_response(workspace, settings):
|
||||||
|
ds, _, _, _, _ = workspace
|
||||||
|
settings.AI_ENABLED = True
|
||||||
|
settings.OPENAI_MODEL = "test-model"
|
||||||
|
settings.OPENAI_API_KEY = "test-key"
|
||||||
|
polled = {"n": 0, "posts": 0}
|
||||||
|
|
||||||
|
class InProgress:
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"status": "in_progress", "id": "resp-1"}
|
||||||
|
|
||||||
|
class ClientMock:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, *args, **kwargs):
|
||||||
|
polled["posts"] += 1
|
||||||
|
# 第一次 POST 返回损坏文本(触发整体重试),第二次 POST 返回 in_progress
|
||||||
|
if polled["posts"] == 1:
|
||||||
|
class Bad:
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"output": [{"content": [{"type": "output_text", "text": "not-json"}]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bad()
|
||||||
|
return InProgress()
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
polled["n"] += 1
|
||||||
|
assert args[0].endswith("/responses/resp-1")
|
||||||
|
return completed_response({"action": "anomalies", "metric": "revenue", "group": "department", "filters": []})
|
||||||
|
|
||||||
|
with patch("finance.assistant.httpx.Client", ClientMock):
|
||||||
|
intent = model_intent("看异常", ds, {"metric": "revenue", "group": "department", "filters": {}})
|
||||||
|
|
||||||
|
assert intent["action"] == "anomalies"
|
||||||
|
assert polled["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_ai_config_requires_full_admin_and_masks_key(workspace):
|
def test_ai_config_requires_full_admin_and_masks_key(workspace):
|
||||||
ds, member, client, _, _ = workspace
|
ds, member, client, _, _ = workspace
|
||||||
url = "/api/ai-config"
|
url = "/api/ai-config"
|
||||||
|
|||||||
Reference in New Issue
Block a user