Files
ai-agent-admin/backend-fastapi/online_dev/report_manager/engine/expression_eval.py
T

492 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表达式求值 MVP(对齐 JNPF expression 子集)
- #{param} 参数占位
- sum/avg/max/min/count(数据集别名.字段)
- sum/avg/max/min/count(A1:B2) 单元格区域
- A1、$B2 单元格引用
- 四则运算(仅数字)
"""
from __future__ import annotations
import ast
import copy
import operator
import re
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.column_layout import parse_cell_range
from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value
def _get_nested_value(row: Dict[str, Any], field: str) -> Any:
if not field:
return None
if field in row:
return row[field]
parts = field.split(".")
cur: Any = row
for p in parts:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return None
return cur
_AGG_FUNCS = ("sum", "avg", "max", "min", "count")
_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([a-zA-Z_][\w.]*)\s*\)",
re.IGNORECASE,
)
_CELL_RANGE_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([A-Za-z]+\d+)\s*:\s*([A-Za-z]+\d+)\s*\)",
re.IGNORECASE,
)
_CELL_REF_PATTERN = re.compile(
r"(?<![A-Za-z0-9.])(\$?)([A-Za-z]{1,3})(\d+)(?![:\w])",
re.IGNORECASE,
)
def _col_letter_to_index(col: str) -> int:
col = col.upper()
n = 0
for ch in col:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n - 1
def _parse_a1(addr: str) -> Optional[Tuple[int, int]]:
"""A1 / $B2 -> (row, col) 0-based"""
if not addr:
return None
m = re.match(r"^\$?([A-Za-z]+)(\d+)$", addr.strip())
if not m:
return None
return int(m.group(2)) - 1, _col_letter_to_index(m.group(1))
def _cell_to_numeric(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _get_cell_value(
snapshot: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
) -> Any:
sheets = snapshot.get("sheets") or {}
sheet = sheets.get(sheet_id) or {}
cell_data = sheet.get("cellData") or {}
row_obj = cell_data.get(str(row)) or {}
cell = row_obj.get(str(col)) or {}
return cell.get("v")
def _collect_cells_in_range(
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> List[float]:
bounds = parse_cell_range(f"{start}:{end}")
if not bounds:
return []
r0, r1, c0, c1 = bounds
values: List[float] = []
for r in range(r0, r1 + 1):
for c in range(c0, c1 + 1):
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, r, c))
if num is not None:
values.append(num)
return values
def _aggregate_cell_range(
func: str,
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> float:
values = _collect_cells_in_range(snapshot, sheet_id, start, end)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_cell_range_aggregates(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
val = _aggregate_cell_range(
m.group(1), snapshot, sheet_id, m.group(2), m.group(3)
)
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _CELL_RANGE_AGG_PATTERN.sub(repl, expr)
def _replace_cell_refs(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if not pos:
return m.group(0)
row, col = pos
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, row, col))
if num is None:
return "0"
if num == int(num):
return str(int(num))
return str(num)
return _CELL_REF_PATTERN.sub(repl, expr)
_SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _parse_dataset_field(ref: str) -> Tuple[Optional[str], str]:
if "." in ref:
parts = ref.split(".", 1)
return parts[0], parts[1]
return None, ref
def _aggregate(func: str, datasets: Dict[str, List[Any]], ref: str) -> float:
alias, field = _parse_dataset_field(ref)
if not alias or not field:
return 0
rows = datasets.get(alias) or []
values: List[float] = []
for row in rows:
if not isinstance(row, dict):
continue
v = _get_nested_value(row, field)
if v is None or v == "":
continue
try:
values.append(float(v))
except (TypeError, ValueError):
if func.lower() == "count":
values.append(1.0)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_aggregates(expr: str, datasets: Dict[str, List[Any]]) -> str:
def repl(m: re.Match) -> str:
val = _aggregate(m.group(1), datasets, m.group(2))
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _AGG_PATTERN.sub(repl, expr)
def _safe_eval_numeric(expr: str) -> Any:
expr = (expr or "").strip()
if not expr:
return ""
node = ast.parse(expr, mode="eval")
return _eval_node(node.body)
def _eval_node(node: ast.AST) -> float:
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError("non-numeric constant")
if isinstance(node, ast.BinOp):
op = _SAFE_OPS.get(type(node.op))
if not op:
raise ValueError("unsupported operator")
return op(_eval_node(node.left), _eval_node(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval_node(node.operand)
raise ValueError("unsupported expression")
def evaluate_formula(
formula: str,
params: Dict[str, Any],
datasets: Dict[str, List[Any]],
snapshot: Optional[Dict[str, Any]] = None,
sheet_id: str = "sheet1",
) -> str:
"""
求值表达式,返回可写入单元格的字符串结果。
支持前缀 '=' 或裸公式。
"""
raw = (formula or "").strip()
if not raw:
return ""
if raw.startswith("="):
raw = raw[1:].strip()
text = _replace_params_in_value(raw, params)
if snapshot:
text = _replace_cell_range_aggregates(text, snapshot, sheet_id)
text = _replace_aggregates(text, datasets)
if snapshot:
text = _replace_cell_refs(text, snapshot, sheet_id)
try:
result = _safe_eval_numeric(text)
if result == int(result):
return str(int(result))
return str(result)
except Exception:
return text
def _extract_formula_cell_refs(formula: str) -> List[Tuple[int, int]]:
"""从公式中提取 A1 风格单元格引用(0-based row, col"""
raw = (formula or "").strip()
if raw.startswith("="):
raw = raw[1:].strip()
refs: List[Tuple[int, int]] = []
seen: set = set()
for m in _CELL_REF_PATTERN.finditer(raw):
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if pos and pos not in seen:
seen.add(pos)
refs.append(pos)
return refs
def _sort_expression_targets(
targets: List[Tuple[str, int, int, str]],
) -> Tuple[List[Tuple[str, int, int, str]], bool]:
"""
按单元格引用依赖拓扑排序表达式目标。
返回 (排序后列表, 是否存在环)。
"""
if len(targets) <= 1:
return targets, False
expr_keys = {(s, r, c) for s, r, c, _ in targets}
deps: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for sheet_id, row, col, formula in targets:
key = (sheet_id, row, col)
for ref_row, ref_col in _extract_formula_cell_refs(formula):
dep_key = (sheet_id, ref_row, ref_col)
if dep_key in expr_keys and dep_key != key:
deps[key].add(dep_key)
in_degree = {k: len(deps[k]) for k in expr_keys}
children: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for key, dep_set in deps.items():
for dep in dep_set:
children[dep].add(key)
queue = sorted(k for k in expr_keys if in_degree[k] == 0)
order: List[Tuple[str, int, int]] = []
while queue:
key = queue.pop(0)
order.append(key)
for child in sorted(children[key]):
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
has_cycle = len(order) != len(expr_keys)
if has_cycle:
return targets, True
key_to_target = {(s, r, c): (s, r, c, f) for s, r, c, f in targets}
return [key_to_target[k] for k in order], False
def _write_expression_cell(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
formula: str,
value: str,
) -> None:
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
cell_obj["v"] = value
display_formula = formula.strip()
if display_formula and not display_formula.startswith("="):
display_formula = f"={display_formula}"
if display_formula:
cell_obj["f"] = display_formula
cell_obj["t"] = 4
custom = cell_obj.get("custom") or {}
custom["type"] = "expression"
custom["field"] = formula
custom["formula"] = display_formula or formula
cell_obj["custom"] = custom
def _collect_expression_targets(
cells_meta: Dict[str, Any],
snapshot: Dict[str, Any],
) -> List[Tuple[str, int, int, str]]:
"""返回 (sheet_id, row, col, formula)"""
targets: List[Tuple[str, int, int, str]] = []
seen: set = set()
for cell in cells_meta.get("cells") or []:
if cell.get("type") != "expression":
continue
sheet_id = cell.get("sheet", "sheet1")
row = int(cell.get("row", 0))
col = int(cell.get("col", 0))
custom = cell.get("custom") or {}
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or ""
)
key = (sheet_id, row, col)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row, col, str(formula)))
sheets = snapshot.get("sheets") or {}
for sheet_id, sheet in sheets.items():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for rk, row in cell_data.items():
if not isinstance(row, dict):
continue
try:
row_i = int(rk)
except ValueError:
continue
for ck, cell in row.items():
if not isinstance(cell, dict):
continue
custom = cell.get("custom") or {}
if custom.get("type") != "expression":
continue
try:
col_i = int(ck)
except ValueError:
continue
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or cell.get("v")
or ""
)
key = (sheet_id, row_i, col_i)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row_i, col_i, str(formula)))
return targets
def detect_expression_cycles(
cells_meta: Dict[str, Any],
snapshot: Optional[Dict[str, Any]] = None,
) -> List[str]:
"""
检测表达式单元格引用环。
返回警告码列表(供预览 API warnings 字段使用)。
"""
snap = snapshot if snapshot is not None else {"sheets": {}}
targets = _collect_expression_targets(cells_meta, snap)
if len(targets) <= 1:
return []
_, has_cycle = _sort_expression_targets(targets)
if has_cycle:
return ["expression_cycle"]
return []
def apply_expression_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
params: Dict[str, Any],
) -> Dict[str, Any]:
if not snapshot:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
targets = _collect_expression_targets(cells_meta, result)
if not targets:
return result
ordered, has_cycle = _sort_expression_targets(targets)
max_passes = min(len(targets) + 1, 32)
def _eval_all(batch: List[Tuple[str, int, int, str]]) -> bool:
changed = False
for sheet_id, row, col, formula in batch:
prev = _get_cell_value(result, sheet_id, row, col)
value = evaluate_formula(formula, params, datasets, result, sheet_id)
if str(prev) != str(value):
changed = True
_write_expression_cell(sheets, sheet_id, row, col, formula, value)
return changed
if not has_cycle:
_eval_all(ordered)
else:
for _ in range(max_passes):
if not _eval_all(targets):
break
result["sheets"] = sheets
return result