diff --git a/finance/assistant.py b/finance/assistant.py index d69f5b1..17dc044 100644 --- a/finance/assistant.py +++ b/finance/assistant.py @@ -1,4 +1,5 @@ import json +import time import httpx 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 +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): cfg = cfg or resolve(ds.organization) if not cfg: @@ -40,7 +95,11 @@ def model_intent(question, ds, ctx, cfg=None): "input": [ { "role": "system", - "content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。保留当前筛选,除非用户明确改变。" + "content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。" + "筛选规则:若用户明确提到某维度的新取值,替换该维度的现有筛选;未提及的维度保留现有筛选。" + "示例:现有筛选为 department=口腔,用户问“下钻看看健康管理”时,健康管理是科室维度的新取值," + "必须返回 filters=[{key:department,value:健康管理}](替换而非叠加口腔);" + "group 取用户指定的下钻维度(如 product),未指定则保持当前 group。" + json.dumps( { "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} }, } - 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 + last_exc = None + for _attempt in range(2): + try: + intent = _extract_intent(_request_response(cfg, payload)) + context(ds, {**ctx, **intent}) + return intent + except (httpx.HTTPError, ValueError, KeyError, TypeError, AttributeError, StopIteration) as exc: + last_exc = exc + raise APIError("模型暂时不可用或返回了无效意图;未执行任何操作", 502) from last_exc @endpoint(("POST",)) diff --git a/frontend/src/style.css b/frontend/src/style.css index bebe5ac..474df62 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -471,6 +471,7 @@ a { .kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); + container-type: inline-size; margin: 0 0 26px; border: 1px solid var(--line); background: white; @@ -510,7 +511,7 @@ a { } .kpi strong { text-align: left; - font-size: 21px; + font-size: clamp(13px, 1.9cqw, 21px); line-height: 1.25; font-weight: 620; font-variant-numeric: tabular-nums; diff --git a/temp-key.txt b/temp-key.txt new file mode 100644 index 0000000..3708f1b --- /dev/null +++ b/temp-key.txt @@ -0,0 +1 @@ +sk-240d1cadaecf4055afd575a8cd530f31 diff --git a/tests/test_assistant_api.py b/tests/test_assistant_api.py index 3a6c349..ad1c12f 100644 --- a/tests/test_assistant_api.py +++ b/tests/test_assistant_api.py @@ -43,6 +43,59 @@ def completed_response(intent=None): 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): ds, member, client, _, _ = workspace url = "/api/ai-config"