feat: restore source parity and harden agent runtime
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user