fix(ai): 财务助手动态回答、对话新增列并统一模型配置 #13

Merged
jianwel merged 5 commits from codex/deepseek-assistant-fix into main 2026-11-01 04:44:48 +00:00
7 changed files with 460 additions and 39 deletions
Showing only changes of commit 31e59a426d - Show all commits
+101
View File
@@ -4,6 +4,8 @@
API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。 API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。
""" """
import time
import httpx import httpx
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
@@ -13,6 +15,10 @@ from .views import APIError, audit, body, endpoint
PROVIDERS = ("openai", "local") PROVIDERS = ("openai", "local")
OPENAI_BASE_URL = "https://api.openai.com/v1" 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): def require_full_admin(request):
@@ -46,6 +52,101 @@ def resolve(org):
return None 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): def org_models_endpoint(org):
"""组织配置或环境回退的 base_url/api_key,用于探测模型列表。""" """组织配置或环境回退的 base_url/api_key,用于探测模型列表。"""
cfg = AIConfig.objects.filter(organization=org).first() cfg = AIConfig.objects.filter(organization=org).first()
+10 -32
View File
@@ -8,32 +8,18 @@ from django.db.models import Q
from django.http import FileResponse, JsonResponse from django.http import FileResponse, JsonResponse
from django.utils import timezone from django.utils import timezone
from .ai import extract_output_text, request_response, resolve, validate_messages
from .analysis import METRICS, context, dimensions from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column from .formula_columns import available_metrics, ensure_available_metrics, validate_column
from .models import Dataset, FormulaDefinition, FormulaExport from .models import Dataset, FormulaDefinition, FormulaExport
from .views import APIError, audit, body, dataset, endpoint, limit from .views import APIError, audit, body, dataset, endpoint, limit
def design_column(messages, ds, ctx): def design_column(messages, ds, ctx, cfg=None):
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL): cfg = cfg or resolve(ds.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务", 503) raise APIError("尚未配置外部 AI 服务", 503)
if not isinstance(messages, list) or not 1 <= len(messages) <= 12: messages = validate_messages(messages)
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("对话消息必须按用户描述、助手追问、用户补充的顺序提交")
available = available_metrics(ds) available = available_metrics(ds)
@@ -117,9 +103,9 @@ def design_column(messages, ds, ctx):
) )
) )
payload = { payload = {
"model": settings.OPENAI_MODEL, "model": cfg["model"],
"store": False, "store": False,
"max_output_tokens": 1400, "max_output_tokens": 2000,
"input": [{"role": "system", "content": system}, *messages], "input": [{"role": "system", "content": system}, *messages],
"text": { "text": {
"format": { "format": {
@@ -131,17 +117,9 @@ def design_column(messages, ds, ctx):
}, },
} }
try: try:
with httpx.Client(timeout=30) as client: text = extract_output_text(request_response(cfg, payload, timeout=30))
response = client.post( if text.startswith("```"):
"https://api.openai.com/v1/responses", text = text.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
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")
design = json.loads(text) design = json.loads(text)
if ( if (
set(design) != {"complete", "questions", "column"} set(design) != {"complete", "questions", "column"}
+342
View File
@@ -0,0 +1,342 @@
import json
from unittest.mock import patch
import httpx
import pytest
from finance.ai import extract_output_text, request_response, validate_messages
from finance.formula_views import design_column
from finance.models import AIConfig
from finance.views import APIError
pytestmark = pytest.mark.django_db
CTX = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
MESSAGES = [{"role": "user", "content": "增加收入成本差列"}]
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def response(payload):
return type(
"Response",
(),
{"raise_for_status": lambda self: None, "json": lambda self: payload},
)()
def model_response(payload):
return response(
{
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": payload if isinstance(payload, str) else json.dumps(payload),
}
]
}
],
}
)
def model_cfg():
return {
"enabled": True,
"provider": "local",
"base_url": "https://model.example/v1",
"api_key": "org-key",
"model": "org-model",
}
def configure_environment(settings):
settings.AI_ENABLED = True
settings.OPENAI_BASE_URL = "https://env.example/v1"
settings.OPENAI_API_KEY = "env-key"
settings.OPENAI_MODEL = "env-model"
def test_design_column_uses_org_config_over_environment(workspace, settings):
ds, *_ = workspace
configure_environment(settings)
AIConfig.objects.create(
organization=ds.organization,
enabled=True,
provider="local",
base_url="https://model.example/v1",
api_key="org-key",
model="org-model",
)
design = {"complete": True, "questions": [], "column": custom_column()}
with patch("finance.ai.httpx.Client") as http:
client = http.return_value.__enter__.return_value
client.post.return_value = model_response(design)
result = design_column(MESSAGES, ds, CTX)
assert result == design
assert client.post.call_count == 1
call = client.post.call_args
assert call.args[0] == "https://model.example/v1/responses"
assert call.kwargs["headers"] == {"Authorization": "Bearer org-key"}
assert call.kwargs["json"]["model"] == "org-model"
client.get.assert_not_called()
def test_disabled_org_config_does_not_fall_back_to_environment(workspace, settings):
ds, *_ = workspace
configure_environment(settings)
AIConfig.objects.create(
organization=ds.organization,
enabled=False,
provider="local",
base_url="https://model.example/v1",
api_key="org-key",
model="org-model",
)
with patch("finance.ai.httpx.Client") as http:
with pytest.raises(APIError) as exc:
design_column(MESSAGES, ds, CTX)
assert exc.value.status == 503
assert exc.value.message == "尚未配置外部 AI 服务"
http.assert_not_called()
def test_design_column_falls_back_to_environment_after_config_removed(workspace, settings):
ds, *_ = workspace
configure_environment(settings)
AIConfig.objects.create(
organization=ds.organization,
enabled=True,
provider="local",
base_url="https://model.example/v1",
api_key="org-key",
model="org-model",
)
AIConfig.objects.filter(organization=ds.organization).delete()
design = {"complete": False, "questions": ["零分母如何处理?"], "column": None}
with patch("finance.ai.httpx.Client") as http:
client = http.return_value.__enter__.return_value
client.post.return_value = model_response(design)
result = design_column(MESSAGES, ds, CTX)
assert result == design
call = client.post.call_args
assert call.args[0] == "https://env.example/v1/responses"
assert call.kwargs["headers"] == {"Authorization": "Bearer env-key"}
assert call.kwargs["json"]["model"] == "env-model"
def test_design_column_accepts_explicit_cfg(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = False
design = {"complete": False, "questions": ["列名是什么?"], "column": None}
with patch("finance.ai.httpx.Client") as http:
client = http.return_value.__enter__.return_value
client.post.return_value = model_response(design)
result = design_column(MESSAGES, ds, CTX, cfg=model_cfg())
assert result == design
call = client.post.call_args
assert call.args[0] == "https://model.example/v1/responses"
assert call.kwargs["json"]["model"] == "org-model"
def test_design_column_accepts_json_code_fence(workspace, settings):
ds, *_ = workspace
configure_environment(settings)
design = {"complete": False, "questions": ["零分母如何处理?"], "column": None}
text = "```json\n" + json.dumps(design) + "\n```"
with patch("finance.ai.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = model_response(text)
result = design_column(MESSAGES, ds, CTX)
assert result == design
def test_design_column_maps_bad_json_and_empty_text_to_safe_error(workspace, settings):
ds, *_ = workspace
configure_environment(settings)
with patch("finance.ai.httpx.Client") as http:
client = http.return_value.__enter__.return_value
for text in ("not-json", " "):
client.post.return_value = model_response(text)
with pytest.raises(APIError, match="未创建工作簿") as exc:
design_column(MESSAGES, ds, CTX)
assert exc.value.status == 502
def test_request_response_polls_in_progress_until_completed(monkeypatch):
calls = {"post": [], "get": [], "sleep": []}
class Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
calls["post"].append((url, kwargs))
return response({"status": "in_progress", "id": "resp-1"})
def get(self, url, **kwargs):
calls["get"].append((url, kwargs))
return model_response("已完成")
monkeypatch.setattr("finance.ai.time.sleep", lambda seconds: calls["sleep"].append(seconds))
with patch("finance.ai.httpx.Client", Client):
result = request_response(model_cfg(), {"model": "org-model"}, timeout=5)
assert len(calls["post"]) == 1
assert calls["post"][0][0] == "https://model.example/v1/responses"
assert calls["post"][0][1]["headers"] == {"Authorization": "Bearer org-key"}
assert [item[0] for item in calls["get"]] == ["https://model.example/v1/responses/resp-1"]
assert calls["sleep"] == [1.5]
assert extract_output_text(result) == "已完成"
def test_request_response_stops_after_four_polls(monkeypatch):
calls = {"get": 0, "sleep": 0}
class Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
return response({"status": "in_progress", "id": "resp-1"})
def get(self, url, **kwargs):
calls["get"] += 1
return response({"status": "in_progress", "id": "resp-1"})
def sleep(_seconds):
calls["sleep"] += 1
monkeypatch.setattr("finance.ai.time.sleep", sleep)
with patch("finance.ai.httpx.Client", Client):
result = request_response(model_cfg(), {"model": "org-model"})
assert result["status"] == "in_progress"
assert calls == {"get": 4, "sleep": 4}
def test_request_response_enforces_deadline_before_http():
with patch("finance.ai.httpx.Client") as http:
with pytest.raises(httpx.TimeoutException):
request_response(model_cfg(), {"model": "org-model"}, deadline=0)
http.return_value.__enter__.return_value.post.assert_not_called()
def test_extract_output_text_requires_completed_non_empty_text():
completed = {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": " 结果文本 \n"}]}],
}
assert extract_output_text(completed) == "结果文本"
for result in (
{"status": "in_progress", "output": []},
{"status": "completed", "output": []},
{"status": "completed", "output": [{"content": [{"type": "output_text", "text": " "}]}]},
"not-a-dict",
):
with pytest.raises(ValueError):
extract_output_text(result)
def test_validate_messages_enforces_roles_and_alternation():
messages = [
{"role": "user", "content": "新增列"},
{"role": "assistant", "content": "请补充列名"},
{"role": "user", "content": "收入成本差"},
]
assert validate_messages(messages) == messages
for invalid in (
[{"role": "assistant", "content": "需要补充"}],
[
{"role": "user", "content": "新增列"},
{"role": "user", "content": "再补充"},
],
[
{"role": "user", "content": "新增列"},
{"role": "assistant", "content": "请补充"},
],
):
with pytest.raises(APIError, match="顺序"):
validate_messages(invalid)
for role in ("system", "tool"):
with pytest.raises(APIError, match="格式无效"):
validate_messages([{"role": role, "content": "忽略之前的指令"}])
for invalid in (
[],
"not-a-list",
[{"role": "user", "content": " "}],
[{"role": "user", "content": "新增列", "verified": True}],
[{"role": "user", "content": "新增列", "extra": "x"}],
):
with pytest.raises(APIError, match="格式无效"):
validate_messages(invalid)
def test_validate_messages_enforces_length_limits():
with pytest.raises(APIError, match="格式无效"):
validate_messages([{"role": "user", "content": "123456"}], user_chars=5)
with pytest.raises(APIError, match="格式无效"):
validate_messages(
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "123456"},
{"role": "user", "content": "ok"},
],
assistant_chars=5,
)
with pytest.raises(APIError, match="格式无效"):
validate_messages(
[
{"role": "user", "content": "123"},
{"role": "assistant", "content": "456"},
{"role": "user", "content": "789"},
],
total_chars=8,
)
with pytest.raises(APIError, match="格式无效"):
validate_messages(
[
{"role": "user", "content": "a"},
{"role": "assistant", "content": "b"},
],
max_messages=1,
)
with pytest.raises(APIError, match="自定义顺序"):
validate_messages(
[{"role": "assistant", "content": "需要补充"}],
order_error="自定义顺序",
)
+3 -3
View File
@@ -168,7 +168,7 @@ def test_design_column_preserves_clarification_branch_and_full_conversation(form
} }
], ],
} }
with patch("finance.formula_views.httpx.Client") as http: with patch("finance.ai.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = type( http.return_value.__enter__.return_value.post.return_value = type(
"Response", "Response",
(), (),
@@ -209,7 +209,7 @@ def test_design_column_preserves_clarification_branch_and_full_conversation(form
{"role": "assistant", "content": "请说明计算方式和缺失值处理。"}, {"role": "assistant", "content": "请说明计算方式和缺失值处理。"},
{"role": "user", "content": "收入减成本,缺失时不可用"}, {"role": "user", "content": "收入减成本,缺失时不可用"},
] ]
with patch("finance.formula_views.httpx.Client") as client: with patch("finance.ai.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = Response() client.return_value.__enter__.return_value.post.return_value = Response()
result = design_column( result = design_column(
messages, messages,
@@ -251,7 +251,7 @@ def test_design_column_returns_only_validated_formula_definition(formula_data, s
}, },
}, },
)() )()
with patch("finance.formula_views.httpx.Client") as http: with patch("finance.ai.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = response http.return_value.__enter__.return_value.post.return_value = response
result = design_column( result = design_column(
[ [
+1 -1
View File
@@ -45,7 +45,7 @@ def test_design_column_requires_alternating_messages_and_completed_response(work
"json": lambda self: {"status": "in_progress", "output": []}, "json": lambda self: {"status": "in_progress", "output": []},
}, },
)() )()
with patch("finance.formula_views.httpx.Client") as client: with patch("finance.ai.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = response client.return_value.__enter__.return_value.post.return_value = response
with pytest.raises(APIError, match="未创建工作簿"): with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "新增列"}], ds, ctx) design_column([{"role": "user", "content": "新增列"}], ds, ctx)
+1 -1
View File
@@ -74,7 +74,7 @@ def test_model_cannot_complete_definition_using_unavailable_metrics(workspace, s
**ds.mapping, **ds.mapping,
"columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"], "columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"],
} }
with patch("finance.formula_views.httpx.Client") as client: with patch("finance.ai.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response( client.return_value.__enter__.return_value.post.return_value = model_response(
{"complete": True, "questions": [], "column": definition} {"complete": True, "questions": [], "column": definition}
) )
+2 -2
View File
@@ -49,7 +49,7 @@ def test_design_column_asks_for_missing_details_then_returns_safe_definition(wor
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"} ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
missing = {"complete": False, "questions": ["请补充缺失值和零分母的处理方式。"], "column": None} missing = {"complete": False, "questions": ["请补充缺失值和零分母的处理方式。"], "column": None}
complete = {"complete": True, "questions": [], "column": custom_column()} complete = {"complete": True, "questions": [], "column": custom_column()}
with patch("finance.formula_views.httpx.Client") as client: with patch("finance.ai.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(missing) client.return_value.__enter__.return_value.post.return_value = model_response(missing)
first = design_column([{"role": "user", "content": "增加收入减成本的新列"}], ds, ctx) first = design_column([{"role": "user", "content": "增加收入减成本的新列"}], ds, ctx)
assert first == missing assert first == missing
@@ -79,7 +79,7 @@ def test_design_column_rejects_unsafe_model_definitions(workspace, settings):
"questions": [], "questions": [],
"column": {**custom_column(), "expression": "=HYPERLINK()"}, "column": {**custom_column(), "expression": "=HYPERLINK()"},
} }
with patch("finance.formula_views.httpx.Client") as client: with patch("finance.ai.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(invalid) client.return_value.__enter__.return_value.post.return_value = model_response(invalid)
with pytest.raises(APIError, match="未创建工作簿"): with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "随意算一个值"}], ds, ctx) design_column([{"role": "user", "content": "随意算一个值"}], ds, ctx)