feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -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