Files
fa-agent/finance/formula_views.py
T

369 lines
15 KiB
Python

import json
from pathlib import Path
import httpx
from django.conf import settings
from django.db import transaction
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, cfg=None):
cfg = cfg or resolve(ds.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务", 503)
messages = validate_messages(messages)
available = available_metrics(ds)
def expression_schema(depth=0):
leaf = [
{
"type": "object",
"properties": {"metric": {"type": "string", "enum": list(available)}},
"required": ["metric"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"number": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
},
"required": ["number"],
"additionalProperties": False,
},
]
if depth >= 8:
return {"anyOf": leaf}
return {
"anyOf": [
*leaf,
{
"type": "object",
"properties": {
"operator": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]},
"left": expression_schema(depth + 1),
"right": expression_schema(depth + 1),
},
"required": ["operator", "left", "right"],
"additionalProperties": False,
},
]
}
schema = {
"type": "object",
"properties": {
"complete": {"type": "boolean"},
"questions": {"type": "array", "items": {"type": "string"}},
"column": {
"anyOf": [
{"type": "null"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"meaning": {"type": "string"},
"scope": {"type": "string"},
"missing": {"type": "string", "enum": ["结果不可用(#N/A);零分母不可用"]},
"expression": expression_schema(),
},
"required": ["name", "meaning", "scope", "missing", "expression"],
"additionalProperties": False,
},
]
},
},
"required": ["complete", "questions", "column"],
"additionalProperties": False,
}
system = (
"你是财务计算定义助手。只可用给定指标生成表达式树,不可输出公式文本、SQL或新字段。"
"available_metrics 是当前数据集可计算指标;其他指标依赖字段未映射,必须说明并追问,不可猜测或替代。"
"必须确认列名、含义、适用范围和缺失值/零分母处理。缺少任何信息时 complete=false、column=null,提出具体中文问题。"
"指标节点{metric:指标key},常数{number:数值},运算{operator:add|subtract|multiply|divide,left:...,right:...}。"
"本系统当前仅支持结果不可用(Excel #N/A)且零分母不可用,不支持按零替代或跳过缺失项。"
"用户补充信息后结合整段对话重新判断,只有公式含义无歧义且能表达时 complete=true。"
+ json.dumps(
{
"metrics": METRICS,
"available_metrics": available,
"unavailable_metrics": {key: value for key, value in METRICS.items() if key not in available},
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("current", "baseline", "filters", "group")},
},
ensure_ascii=False,
)
)
payload = {
"model": cfg["model"],
"store": False,
"max_output_tokens": 2000,
"input": [{"role": "system", "content": system}, *messages],
"text": {
"format": {
"type": "json_schema",
"name": "formula_column_design",
"strict": True,
"schema": schema,
}
},
}
try:
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"}
or type(design["complete"]) is not bool
or not isinstance(design["questions"], list)
or len(design["questions"]) > 8
):
raise ValueError("invalid response")
if design["complete"]:
if design["column"] is None:
raise ValueError("complete response has no column")
design["column"] = validate_column(design["column"])
try:
ensure_available_metrics(ds, design["column"])
except ValueError as exc:
return {"complete": False, "questions": [str(exc)], "column": None}
if design["questions"]:
raise ValueError("complete response contains questions")
elif (
design["column"] is not None
or not design["questions"]
or any(not isinstance(q, str) or not q.strip() or len(q) > 500 for q in design["questions"])
):
raise ValueError("incomplete response malformed")
return design
except (httpx.HTTPError, ValueError, KeyError, TypeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了不安全的计算定义;未创建工作簿", 502) from exc
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_design(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
if request.member.departments:
raise APIError("科室授权账号不可定义跨范围公式导出", 403)
ctx = context(ds, data.get("context", {}))
if data.get("consent") is not True:
raise APIError("请先同意发送列描述和筛选范围到外部 AI", 400)
limit(f"formula-design:{request.user.pk}", 20, 60)
result = design_column(data.get("messages"), ds, ctx)
audit(request, "formula_export.design", ds.pk, {"complete": result["complete"]})
return JsonResponse(result)
@endpoint(("GET", "POST"), roles=("admin", "analyst"))
def formula_definitions(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET":
items = (
FormulaDefinition.objects.filter(dataset=ds)
.filter(Q(user=request.user) | Q(shared=True))
.select_related("user")
.order_by("normalized_name")
)
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": item.user_id == request.user.pk,
"owner": item.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(),
}
for item in items
]
}
)
data = body(request)
definition = validate_column(data.get("definition"))
shared = data.get("shared", False)
if type(shared) is not bool:
raise APIError("共享选项无效")
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
requested_revision = data.get("revision")
with transaction.atomic():
Dataset.objects.select_for_update().get(pk=ds.pk)
existing = (
FormulaDefinition.objects.select_for_update()
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
.first()
)
if existing:
if requested_revision is not None and (
type(requested_revision) is not int or requested_revision != existing.revision
):
raise APIError("公式定义已在其他窗口更新,请重新载入", 409)
# ponytail: retain 20 revisions; use a separate history table if long-term retention is required.
existing.history = [
*existing.history,
{
"revision": existing.revision,
"definition": existing.definition,
"updated_at": existing.updated_at.isoformat(),
},
][-20:]
existing.definition = definition
existing.shared = shared if "shared" in data else existing.shared
existing.revision += 1
existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"])
item, created = existing, False
else:
if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item = FormulaDefinition.objects.create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
definition=definition,
shared=shared,
)
created = True
audit(
request,
"formula_definition.save",
item.pk,
{
"dataset": str(ds.pk),
"name": definition["name"],
"shared": item.shared,
"revision": item.revision,
},
)
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": True,
"owner": request.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
)
@endpoint(("DELETE",), roles=("admin", "analyst"))
def formula_definition_delete(request, dataset_id, definition_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
item = FormulaDefinition.objects.filter(pk=definition_id, dataset=ds, user=request.user).first()
if not item:
raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "")
with transaction.atomic():
item = FormulaDefinition.objects.select_for_update().get(pk=item.pk)
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
return JsonResponse({"deleted": True})
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_export_create(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可创建公式导出", 403)
data = body(request)
ctx = context(ds, data)
definitions = data.get("custom_columns", [])
if not isinstance(definitions, list) or len(definitions) > 10:
raise APIError("自定义列最多 10 个")
for item in definitions:
validate_column(item)
try:
ensure_available_metrics(ds, item)
except ValueError as exc:
raise APIError(str(exc)) from exc
limit(f"formula-export:{request.user.pk}", 5, 3600)
job = FormulaExport.objects.create(
dataset=ds,
user=request.user,
context={**ctx, "custom_columns": definitions},
departments=list(request.member.departments),
)
audit(
request,
"formula_export.create",
job.pk,
{"dataset": str(ds.pk), "columns": [x["name"] for x in definitions]},
)
return JsonResponse({"id": str(job.pk), "status": job.status}, status=202)
def _owned_job(request, dataset_id, export_id):
if not request.user.is_active or request.member.role not in ("admin", "analyst"):
raise APIError("没有此导出任务的访问权限", 403)
job = FormulaExport.objects.filter(
pk=export_id,
dataset_id=dataset_id,
dataset__organization=request.member.organization,
user=request.user,
).first()
if not job:
raise APIError("导出任务不存在", 404)
if set(request.member.departments) != set(job.departments):
raise APIError("授权范围已变化,不能访问此导出", 403)
return job
@endpoint(("GET",))
def formula_export_status(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
return JsonResponse(
{
"id": str(job.pk),
"status": job.status,
"error": "生成失败,请联系管理员并提供任务编号" if job.status == "failed" else "",
"expires_at": job.expires_at.isoformat() if job.expires_at else None,
}
)
@endpoint(("GET",))
def formula_export_download(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
raise APIError("文件尚未就绪或已过期", 410 if job.status == "succeeded" else 409)
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
candidate = Path(job.path)
expected = export_root / str(job.user_id) / f"{job.pk}.xlsx"
root = export_root.resolve()
path = candidate.resolve()
if (
export_root.is_symlink()
or candidate.is_symlink()
or candidate.parent.is_symlink()
or path != expected.resolve()
or not path.is_relative_to(root)
or not path.is_file()
):
raise APIError("导出文件不存在", 410)
audit(request, "formula_export.download", job.pk)
return FileResponse(path.open("rb"), as_attachment=True, filename="traceiq-formula-analysis.xlsx")