/ restai / projects / block_interpreter.py
block_interpreter.py
   1  """Server-side Blockly workspace interpreter.
   2  
   3  Walks a Blockly JSON tree and executes each block in Python. Supports the
   4  RESTai custom blocks (Get Input, Set Output, Call Project, Classifier, Log)
   5  plus the Blockly 12 general-purpose built-in blocks matching MIT App
   6  Inventor's breadth (Logic, Control, Math, Text, Lists, Variables,
   7  Procedures).
   8  
   9  Dispatch is via two tables built in `__init__` (`_stmt_handlers`,
  10  `_value_handlers`) keyed by the block's `type` string. Unknown types fall
  11  back to best-effort value evaluation (for side-effect-only custom blocks)
  12  or return `None`, matching the pre-refactor behavior.
  13  
  14  Flow control:
  15  - `_BlockBreak` / `_BlockContinue` propagate out of statement handlers and
  16    are caught by the enclosing loop handler.
  17  - `_BlockReturn(value)` propagates out until caught by a procedure call
  18    handler.
  19  """
  20  import inspect
  21  import logging
  22  import math
  23  import random
  24  import statistics
  25  from typing import Any
  26  
  27  from fastapi import HTTPException
  28  
  29  logger = logging.getLogger(__name__)
  30  
  31  MAX_ITERATIONS = 10000
  32  
  33  
  34  class _BlockBreak(Exception):
  35      """Raised by `controls_flow_statements(BREAK)` to exit the nearest loop."""
  36  
  37  
  38  class _BlockContinue(Exception):
  39      """Raised by `controls_flow_statements(CONTINUE)` to skip to the next iteration."""
  40  
  41  
  42  class _BlockReturn(Exception):
  43      """Raised by `procedures_ifreturn` to unwind to the enclosing procedure call."""
  44  
  45      def __init__(self, value):
  46          self.value = value
  47  
  48  
  49  class BlockInterpreter:
  50      """Walks a Blockly workspace JSON tree and interprets each block in Python."""
  51  
  52      def __init__(self, workspace_json: dict, input_text: str, brain, user, db, image=None, chat_id=None, context=None):
  53          self.workspace = workspace_json
  54          self.input_text = input_text
  55          self.image = image
  56          self.output_text = ""
  57          self.variables: dict[str, Any] = {}
  58          self.brain = brain
  59          self.user = user
  60          self.db = db
  61          self.logs: list[str] = []
  62          self._iterations = 0
  63          self.chat_id = chat_id
  64          self.context = context  # Verified context dict (from widget JWT or playground)
  65          self._fake_request = type("_FakeRequest", (), {
  66              "app": type("App", (), {"state": type("State", (), {"brain": brain})()})()
  67          })()
  68          # Procedure registry: name -> {"block": def_block, "params": [{"id": ..., "name": ...}], "has_return": bool}
  69          self.procedures: dict[str, dict] = {}
  70          # Frame stack for procedure-local variables. The top frame is checked
  71          # first by variables_get / variables_set before falling through to
  72          # self.variables (globals).
  73          self._scope_stack: list[dict] = []
  74  
  75          self._stmt_handlers = {
  76              "variables_set": self._stmt_variables_set,
  77              "restai_set_output": self._stmt_set_output,
  78              "restai_log": self._stmt_log,
  79              "controls_if": self._stmt_controls_if,
  80              "controls_repeat_ext": self._stmt_repeat,
  81              "controls_whileUntil": self._stmt_while_until,
  82              "controls_for": self._stmt_for,
  83              "controls_forEach": self._stmt_for_each,
  84              "controls_flow_statements": self._stmt_flow,
  85              "text_append": self._stmt_text_append,
  86              "text_print": self._stmt_text_print,
  87              "lists_setIndex": self._stmt_lists_setIndex,
  88              "lists_getIndex": self._stmt_lists_getIndex_remove,
  89              # Procedure definitions are registered at execute() start and are
  90              # a no-op when encountered in the statement stream.
  91              "procedures_defnoreturn": self._stmt_noop,
  92              "procedures_defreturn": self._stmt_noop,
  93              "procedures_callnoreturn": self._stmt_procedures_call,
  94              "procedures_ifreturn": self._stmt_procedures_ifreturn,
  95          }
  96  
  97          self._value_handlers = {
  98              # --- Literals ---
  99              "text": lambda b: b.get("fields", {}).get("TEXT", ""),
 100              "math_number": lambda b: b.get("fields", {}).get("NUM", 0),
 101              "logic_boolean": lambda b: b.get("fields", {}).get("BOOL", "TRUE") == "TRUE",
 102              "logic_null": lambda b: None,
 103              "math_constant": self._eval_math_constant,
 104              # --- Variables ---
 105              "variables_get": self._eval_variables_get,
 106              # --- Logic ---
 107              "logic_compare": self._eval_logic_compare,
 108              "logic_operation": self._eval_logic_operation,
 109              "logic_negate": self._eval_logic_negate,
 110              "logic_ternary": self._eval_logic_ternary,
 111              # --- Math ---
 112              "math_arithmetic": self._eval_math_arithmetic,
 113              "math_single": self._eval_math_single,
 114              "math_trig": self._eval_math_trig,
 115              "math_number_property": self._eval_math_number_property,
 116              "math_round": self._eval_math_round,
 117              "math_on_list": self._eval_math_on_list,
 118              "math_modulo": self._eval_math_modulo,
 119              "math_constrain": self._eval_math_constrain,
 120              "math_random_int": self._eval_math_random_int,
 121              "math_random_float": self._eval_math_random_float,
 122              "math_atan2": self._eval_math_atan2,
 123              # --- Text ---
 124              "text_join": self._eval_text_join,
 125              "text_length": self._eval_text_length,
 126              "text_isEmpty": self._eval_text_isEmpty,
 127              "text_indexOf": self._eval_text_indexOf,
 128              "text_charAt": self._eval_text_charAt,
 129              "text_changeCase": self._eval_text_changeCase,
 130              "text_trim": self._eval_text_trim,
 131              "text_contains": self._eval_text_contains,
 132              "text_getSubstring": self._eval_text_getSubstring,
 133              "text_count": self._eval_text_count,
 134              "text_replace": self._eval_text_replace,
 135              "text_reverse": self._eval_text_reverse,
 136              # --- Lists ---
 137              "lists_create_with": self._eval_lists_create_with,
 138              "lists_create_empty": lambda b: [],
 139              "lists_repeat": self._eval_lists_repeat,
 140              "lists_length": self._eval_lists_length,
 141              "lists_isEmpty": self._eval_lists_isEmpty,
 142              "lists_indexOf": self._eval_lists_indexOf,
 143              "lists_getIndex": self._eval_lists_getIndex,
 144              "lists_getSublist": self._eval_lists_getSublist,
 145              "lists_split": self._eval_lists_split,
 146              "lists_sort": self._eval_lists_sort,
 147              "lists_reverse": self._eval_lists_reverse,
 148              # --- Procedures ---
 149              "procedures_callreturn": self._eval_procedures_callreturn,
 150              # --- RESTai custom ---
 151              "restai_get_input": lambda b: self.input_text,
 152              "restai_call_project": self._eval_call_project,
 153              "restai_classifier": self._eval_classifier,
 154          }
 155  
 156      def _tick(self):
 157          self._iterations += 1
 158          if self._iterations > MAX_ITERATIONS:
 159              raise HTTPException(
 160                  status_code=400,
 161                  detail="Block execution exceeded maximum iterations.",
 162              )
 163  
 164      # ------------------------------------------------------------------
 165      # Variable scope helpers
 166      # ------------------------------------------------------------------
 167  
 168      def _get_var(self, var_id: str) -> Any:
 169          """Look up a variable: top procedure frame first, then globals."""
 170          if self._scope_stack:
 171              frame = self._scope_stack[-1]
 172              if var_id in frame:
 173                  return frame[var_id]
 174          return self.variables.get(var_id, "")
 175  
 176      def _set_var(self, var_id: str, value: Any) -> None:
 177          """Write to the top procedure frame if the var is scoped there (a
 178          parameter of the current procedure), else to globals."""
 179          if self._scope_stack:
 180              frame = self._scope_stack[-1]
 181              if var_id in frame:
 182                  frame[var_id] = value
 183                  return
 184          self.variables[var_id] = value
 185  
 186      # ------------------------------------------------------------------
 187      # Procedure registration
 188      # ------------------------------------------------------------------
 189  
 190      def _register_procedures(self):
 191          """Scan top-level + next-chained blocks, register every procedure def
 192          into `self.procedures`. Called once at the start of `execute()`."""
 193          def _walk(block):
 194              if block is None:
 195                  return
 196              btype = block.get("type", "")
 197              if btype in ("procedures_defnoreturn", "procedures_defreturn"):
 198                  extra = block.get("extraState", {}) or {}
 199                  # Blockly 12 procedure def blocks carry:
 200                  #   fields.NAME = proc name
 201                  #   extraState.params = [{name, id}, ...]  (or 'arguments' in older versions)
 202                  name = block.get("fields", {}).get("NAME", "") or extra.get("name", "")
 203                  params = extra.get("params") or extra.get("arguments") or []
 204                  # Normalise: ensure each has name + id
 205                  norm_params = []
 206                  for p in params:
 207                      if isinstance(p, dict):
 208                          norm_params.append({
 209                              "name": p.get("name", ""),
 210                              "id": p.get("id") or p.get("varid") or p.get("name", ""),
 211                          })
 212                  if name:
 213                      self.procedures[name] = {
 214                          "block": block,
 215                          "params": norm_params,
 216                          "has_return": btype == "procedures_defreturn",
 217                      }
 218              nxt = block.get("next", {}).get("block")
 219              _walk(nxt)
 220  
 221          for block in self.workspace.get("blocks", {}).get("blocks", []):
 222              _walk(block)
 223  
 224      async def execute(self) -> str:
 225          blocks = self.workspace.get("blocks", {}).get("blocks", [])
 226          # Initialise declared variables
 227          for var in self.workspace.get("variables", []):
 228              self.variables[var["id"]] = ""
 229          # Pre-register procedure definitions so calls can resolve them
 230          # regardless of lexical order in the workspace.
 231          self._register_procedures()
 232          for block in blocks:
 233              try:
 234                  await self._exec_statement(block)
 235              except (_BlockBreak, _BlockContinue, _BlockReturn):
 236                  # Stray flow-control outside any loop/procedure — swallow.
 237                  pass
 238          return self.output_text
 239  
 240      # ------------------------------------------------------------------
 241      # Statement execution
 242      # ------------------------------------------------------------------
 243  
 244      async def _exec_statement(self, block: dict):
 245          self._tick()
 246          btype = block.get("type", "")
 247  
 248          handler = self._stmt_handlers.get(btype)
 249          if handler is not None:
 250              await handler(block)
 251          else:
 252              # Unknown statement — try evaluating as a value (side-effect blocks
 253              # like restai_call_project can be dropped into the statement stream).
 254              try:
 255                  await self._eval_value(block)
 256              except (_BlockBreak, _BlockContinue, _BlockReturn):
 257                  raise
 258              except Exception:
 259                  pass
 260  
 261          # Follow the next chain.
 262          nxt = block.get("next", {}).get("block")
 263          if nxt:
 264              await self._exec_statement(nxt)
 265  
 266      async def _stmt_noop(self, block: dict):
 267          """For procedure definitions: body is only executed via calls."""
 268          return
 269  
 270      async def _stmt_variables_set(self, block: dict):
 271          var_id = block.get("fields", {}).get("VAR", {}).get("id", "")
 272          val = await self._eval_input(block, "VALUE")
 273          self._set_var(var_id, val)
 274  
 275      async def _stmt_set_output(self, block: dict):
 276          val = await self._eval_input(block, "VALUE")
 277          self.output_text = str(val) if val is not None else ""
 278  
 279      async def _stmt_log(self, block: dict):
 280          val = await self._eval_input(block, "TEXT")
 281          msg = str(val) if val is not None else ""
 282          self.logs.append(msg)
 283          logger.info("Block log: %s", msg)
 284  
 285      async def _stmt_text_print(self, block: dict):
 286          # text_print behaves like restai_log for our server-side interpreter.
 287          val = await self._eval_input(block, "TEXT")
 288          msg = str(val) if val is not None else ""
 289          self.logs.append(msg)
 290          logger.info("Block print: %s", msg)
 291  
 292      async def _stmt_text_append(self, block: dict):
 293          var_id = block.get("fields", {}).get("VAR", {}).get("id", "")
 294          val = await self._eval_input(block, "TEXT")
 295          current = self._get_var(var_id)
 296          self._set_var(var_id, str(current) + (str(val) if val is not None else ""))
 297  
 298      async def _stmt_flow(self, block: dict):
 299          flow = block.get("fields", {}).get("FLOW", "BREAK")
 300          if flow == "CONTINUE":
 301              raise _BlockContinue()
 302          raise _BlockBreak()
 303  
 304      async def _stmt_controls_if(self, block: dict):
 305          # Blockly if/elseif/else: inputs IF0, DO0, IF1, DO1, ... ELSE
 306          i = 0
 307          executed = False
 308          while True:
 309              cond_input = block.get("inputs", {}).get(f"IF{i}")
 310              do_input = block.get("inputs", {}).get(f"DO{i}")
 311              if cond_input is None:
 312                  break
 313              cond = await self._eval_value(cond_input.get("block")) if cond_input.get("block") else False
 314              if cond:
 315                  if do_input and do_input.get("block"):
 316                      await self._exec_statement(do_input["block"])
 317                  executed = True
 318                  break
 319              i += 1
 320  
 321          if not executed:
 322              else_input = block.get("inputs", {}).get("ELSE")
 323              if else_input and else_input.get("block"):
 324                  await self._exec_statement(else_input["block"])
 325  
 326      async def _stmt_repeat(self, block: dict):
 327          times = await self._eval_input(block, "TIMES")
 328          try:
 329              times = int(times)
 330          except (TypeError, ValueError):
 331              times = 0
 332          do_input = block.get("inputs", {}).get("DO")
 333          for _ in range(min(times, MAX_ITERATIONS)):
 334              self._tick()
 335              if do_input and do_input.get("block"):
 336                  try:
 337                      await self._exec_statement(do_input["block"])
 338                  except _BlockContinue:
 339                      continue
 340                  except _BlockBreak:
 341                      break
 342  
 343      async def _stmt_while_until(self, block: dict):
 344          mode = block.get("fields", {}).get("MODE", "WHILE")
 345          do_input = block.get("inputs", {}).get("DO")
 346          for _ in range(MAX_ITERATIONS):
 347              self._tick()
 348              cond = await self._eval_input(block, "BOOL")
 349              if mode == "WHILE" and not cond:
 350                  break
 351              if mode == "UNTIL" and cond:
 352                  break
 353              if do_input and do_input.get("block"):
 354                  try:
 355                      await self._exec_statement(do_input["block"])
 356                  except _BlockContinue:
 357                      continue
 358                  except _BlockBreak:
 359                      break
 360  
 361      async def _stmt_for(self, block: dict):
 362          var_id = block.get("fields", {}).get("VAR", {}).get("id", "")
 363          from_val = await self._eval_input(block, "FROM")
 364          to_val = await self._eval_input(block, "TO")
 365          by_val = await self._eval_input(block, "BY")
 366          try:
 367              from_val, to_val, by_val = int(from_val), int(to_val), int(by_val)
 368          except (TypeError, ValueError):
 369              return
 370          if by_val == 0:
 371              return
 372          do_input = block.get("inputs", {}).get("DO")
 373          if from_val <= to_val:
 374              rng = range(from_val, to_val + 1, abs(by_val))
 375          else:
 376              rng = range(from_val, to_val - 1, -abs(by_val))
 377          for i in rng:
 378              self._tick()
 379              self._set_var(var_id, i)
 380              if do_input and do_input.get("block"):
 381                  try:
 382                      await self._exec_statement(do_input["block"])
 383                  except _BlockContinue:
 384                      continue
 385                  except _BlockBreak:
 386                      break
 387  
 388      async def _stmt_for_each(self, block: dict):
 389          var_id = block.get("fields", {}).get("VAR", {}).get("id", "")
 390          lst = await self._eval_input(block, "LIST")
 391          if not isinstance(lst, list):
 392              return
 393          do_input = block.get("inputs", {}).get("DO")
 394          for item in lst:
 395              self._tick()
 396              self._set_var(var_id, item)
 397              if do_input and do_input.get("block"):
 398                  try:
 399                      await self._exec_statement(do_input["block"])
 400                  except _BlockContinue:
 401                      continue
 402                  except _BlockBreak:
 403                      break
 404  
 405      async def _stmt_lists_setIndex(self, block: dict):
 406          """lists_setIndex: mutate the list at an index (SET or INSERT)."""
 407          lst = await self._eval_input(block, "LIST")
 408          if not isinstance(lst, list):
 409              return
 410          mode = block.get("fields", {}).get("MODE", "SET")
 411          where = block.get("fields", {}).get("WHERE", "FROM_START")
 412          val = await self._eval_input(block, "TO")
 413          at_val = None
 414          if where in ("FROM_START", "FROM_END"):
 415              at_val = await self._eval_input(block, "AT")
 416  
 417          idx = self._resolve_list_index(len(lst), where, at_val, for_insert=(mode == "INSERT"))
 418          if idx is None:
 419              return
 420          if mode == "SET":
 421              if 0 <= idx < len(lst):
 422                  lst[idx] = val
 423          elif mode == "INSERT":
 424              if 0 <= idx <= len(lst):
 425                  lst.insert(idx, val)
 426  
 427      async def _stmt_lists_getIndex_remove(self, block: dict):
 428          """lists_getIndex in statement position — only MODE=REMOVE lands here."""
 429          mode = block.get("fields", {}).get("MODE", "GET")
 430          if mode != "REMOVE":
 431              # GET / GET_REMOVE are values; if they appear as statements they'd be
 432              # a side-effect-only evaluation. Fall through silently.
 433              await self._eval_lists_getIndex(block)
 434              return
 435          lst = await self._eval_input(block, "VALUE")
 436          if not isinstance(lst, list):
 437              return
 438          where = block.get("fields", {}).get("WHERE", "FROM_START")
 439          at_val = None
 440          if where in ("FROM_START", "FROM_END"):
 441              at_val = await self._eval_input(block, "AT")
 442          idx = self._resolve_list_index(len(lst), where, at_val)
 443          if idx is not None and 0 <= idx < len(lst):
 444              del lst[idx]
 445  
 446      async def _stmt_procedures_call(self, block: dict):
 447          await self._invoke_procedure(block, want_return=False)
 448  
 449      async def _stmt_procedures_ifreturn(self, block: dict):
 450          cond = await self._eval_input(block, "CONDITION")
 451          if cond:
 452              val = await self._eval_input(block, "VALUE")
 453              raise _BlockReturn(val)
 454  
 455      # ------------------------------------------------------------------
 456      # Value evaluation
 457      # ------------------------------------------------------------------
 458  
 459      async def _eval_input(self, block: dict, input_name: str) -> Any:
 460          inp = block.get("inputs", {}).get(input_name)
 461          if inp and inp.get("block"):
 462              return await self._eval_value(inp["block"])
 463          # Shadow / field fallback
 464          if inp and inp.get("shadow"):
 465              return await self._eval_value(inp["shadow"])
 466          return None
 467  
 468      async def _eval_value(self, block: dict) -> Any:
 469          if block is None:
 470              return None
 471          self._tick()
 472          btype = block.get("type", "")
 473  
 474          handler = self._value_handlers.get(btype)
 475          if handler is None:
 476              return None
 477  
 478          # Handlers may be sync (lambdas for cheap literals) or async.
 479          result = handler(block)
 480          if inspect.iscoroutine(result):
 481              result = await result
 482          return result
 483  
 484      # ------------------------------------------------------------------
 485      # Variables
 486      # ------------------------------------------------------------------
 487  
 488      def _eval_variables_get(self, block: dict) -> Any:
 489          var_id = block.get("fields", {}).get("VAR", {}).get("id", "")
 490          return self._get_var(var_id)
 491  
 492      # ------------------------------------------------------------------
 493      # Text helpers (existing + new)
 494      # ------------------------------------------------------------------
 495  
 496      async def _eval_text_join(self, block: dict) -> str:
 497          items = block.get("extraState", {}).get("itemCount", 0)
 498          parts = []
 499          for i in range(items):
 500              val = await self._eval_input(block, f"ADD{i}")
 501              parts.append(str(val) if val is not None else "")
 502          return "".join(parts)
 503  
 504      async def _eval_text_length(self, block: dict) -> int:
 505          val = await self._eval_input(block, "VALUE")
 506          return len(str(val)) if val is not None else 0
 507  
 508      async def _eval_text_isEmpty(self, block: dict) -> bool:
 509          val = await self._eval_input(block, "VALUE")
 510          return str(val).strip() == "" if val is not None else True
 511  
 512      async def _eval_text_contains(self, block: dict) -> bool:
 513          haystack = await self._eval_input(block, "VALUE")
 514          needle = await self._eval_input(block, "FIND")
 515          return str(needle) in str(haystack) if haystack is not None else False
 516  
 517      async def _eval_text_indexOf(self, block: dict) -> int:
 518          val = await self._eval_input(block, "VALUE")
 519          find = await self._eval_input(block, "FIND")
 520          end = block.get("fields", {}).get("END", "FIRST")
 521          s = str(val) if val is not None else ""
 522          f = str(find) if find is not None else ""
 523          if end == "FIRST":
 524              idx = s.find(f)
 525          else:
 526              idx = s.rfind(f)
 527          return idx
 528  
 529      async def _eval_text_charAt(self, block: dict) -> str:
 530          val = await self._eval_input(block, "VALUE")
 531          where = block.get("fields", {}).get("WHERE", "FROM_START")
 532          s = str(val) if val is not None else ""
 533          if not s:
 534              return ""
 535          if where == "FIRST":
 536              return s[0]
 537          if where == "LAST":
 538              return s[-1]
 539          if where == "FROM_START":
 540              at = await self._eval_input(block, "AT")
 541              try:
 542                  return s[int(at)]
 543              except (TypeError, ValueError, IndexError):
 544                  return ""
 545          if where == "FROM_END":
 546              at = await self._eval_input(block, "AT")
 547              try:
 548                  return s[-(int(at) + 1)]
 549              except (TypeError, ValueError, IndexError):
 550                  return ""
 551          if where == "RANDOM":
 552              return random.choice(s)
 553          return ""
 554  
 555      async def _eval_text_changeCase(self, block: dict) -> str:
 556          val = await self._eval_input(block, "TEXT")
 557          case = block.get("fields", {}).get("CASE", "UPPERCASE")
 558          s = str(val) if val is not None else ""
 559          if case == "UPPERCASE":
 560              return s.upper()
 561          if case == "LOWERCASE":
 562              return s.lower()
 563          if case == "TITLECASE":
 564              return s.title()
 565          return s
 566  
 567      async def _eval_text_trim(self, block: dict) -> str:
 568          val = await self._eval_input(block, "TEXT")
 569          mode = block.get("fields", {}).get("MODE", "BOTH")
 570          s = str(val) if val is not None else ""
 571          if mode == "LEFT":
 572              return s.lstrip()
 573          if mode == "RIGHT":
 574              return s.rstrip()
 575          return s.strip()
 576  
 577      async def _eval_text_getSubstring(self, block: dict) -> str:
 578          val = await self._eval_input(block, "STRING")
 579          s = str(val) if val is not None else ""
 580          where1 = block.get("fields", {}).get("WHERE1", "FROM_START")
 581          where2 = block.get("fields", {}).get("WHERE2", "FROM_START")
 582          at1 = None
 583          at2 = None
 584          if where1 in ("FROM_START", "FROM_END"):
 585              at1 = await self._eval_input(block, "AT1")
 586          if where2 in ("FROM_START", "FROM_END"):
 587              at2 = await self._eval_input(block, "AT2")
 588          start = self._resolve_sequence_slice(len(s), where1, at1, end=False)
 589          end = self._resolve_sequence_slice(len(s), where2, at2, end=True)
 590          if start is None or end is None or start > end:
 591              return ""
 592          return s[start:end]
 593  
 594      async def _eval_text_count(self, block: dict) -> int:
 595          sub = await self._eval_input(block, "SUB")
 596          txt = await self._eval_input(block, "TEXT")
 597          sub = str(sub) if sub is not None else ""
 598          txt = str(txt) if txt is not None else ""
 599          if not sub:
 600              return 0
 601          return txt.count(sub)
 602  
 603      async def _eval_text_replace(self, block: dict) -> str:
 604          find = await self._eval_input(block, "FROM")
 605          to = await self._eval_input(block, "TO")
 606          txt = await self._eval_input(block, "TEXT")
 607          find = str(find) if find is not None else ""
 608          to = str(to) if to is not None else ""
 609          txt = str(txt) if txt is not None else ""
 610          if not find:
 611              return txt
 612          return txt.replace(find, to)
 613  
 614      async def _eval_text_reverse(self, block: dict) -> str:
 615          val = await self._eval_input(block, "TEXT")
 616          s = str(val) if val is not None else ""
 617          return s[::-1]
 618  
 619      # ------------------------------------------------------------------
 620      # Math helpers (existing + new)
 621      # ------------------------------------------------------------------
 622  
 623      async def _eval_math_arithmetic(self, block: dict):
 624          a = await self._eval_input(block, "A")
 625          b = await self._eval_input(block, "B")
 626          op = block.get("fields", {}).get("OP", "ADD")
 627          try:
 628              a, b = float(a), float(b)
 629          except (TypeError, ValueError):
 630              return 0
 631          if op == "ADD":
 632              return a + b
 633          if op == "MINUS":
 634              return a - b
 635          if op == "MULTIPLY":
 636              return a * b
 637          if op == "DIVIDE":
 638              return a / b if b != 0 else 0
 639          if op == "POWER":
 640              return a**b
 641          return 0
 642  
 643      async def _eval_math_single(self, block: dict):
 644          val = await self._eval_input(block, "NUM")
 645          op = block.get("fields", {}).get("OP", "ROOT")
 646          try:
 647              n = float(val)
 648          except (TypeError, ValueError):
 649              return 0
 650          try:
 651              if op == "ROOT":
 652                  return math.sqrt(n) if n >= 0 else 0
 653              if op == "ABS":
 654                  return abs(n)
 655              if op == "NEG":
 656                  return -n
 657              if op == "LN":
 658                  return math.log(n) if n > 0 else 0
 659              if op == "LOG10":
 660                  return math.log10(n) if n > 0 else 0
 661              if op == "EXP":
 662                  return math.exp(n)
 663              if op == "POW10":
 664                  return 10 ** n
 665          except (ValueError, OverflowError):
 666              return 0
 667          return 0
 668  
 669      async def _eval_math_trig(self, block: dict):
 670          val = await self._eval_input(block, "NUM")
 671          op = block.get("fields", {}).get("OP", "SIN")
 672          try:
 673              n = float(val)
 674          except (TypeError, ValueError):
 675              return 0
 676          try:
 677              if op == "SIN":
 678                  return math.sin(math.radians(n))
 679              if op == "COS":
 680                  return math.cos(math.radians(n))
 681              if op == "TAN":
 682                  return math.tan(math.radians(n))
 683              if op == "ASIN":
 684                  return math.degrees(math.asin(n))
 685              if op == "ACOS":
 686                  return math.degrees(math.acos(n))
 687              if op == "ATAN":
 688                  return math.degrees(math.atan(n))
 689          except (ValueError, OverflowError):
 690              return 0
 691          return 0
 692  
 693      def _eval_math_constant(self, block: dict):
 694          c = block.get("fields", {}).get("CONSTANT", "PI")
 695          return {
 696              "PI": math.pi,
 697              "E": math.e,
 698              "GOLDEN_RATIO": (1 + math.sqrt(5)) / 2,
 699              "SQRT2": math.sqrt(2),
 700              "SQRT1_2": math.sqrt(0.5),
 701              "INFINITY": math.inf,
 702          }.get(c, 0)
 703  
 704      async def _eval_math_number_property(self, block: dict):
 705          val = await self._eval_input(block, "NUMBER_TO_CHECK")
 706          prop = block.get("fields", {}).get("PROPERTY", "EVEN")
 707          try:
 708              n = float(val)
 709          except (TypeError, ValueError):
 710              return False
 711          if prop == "EVEN":
 712              return n == int(n) and int(n) % 2 == 0
 713          if prop == "ODD":
 714              return n == int(n) and int(n) % 2 != 0
 715          if prop == "PRIME":
 716              if n != int(n) or int(n) < 2:
 717                  return False
 718              ni = int(n)
 719              if ni == 2:
 720                  return True
 721              if ni % 2 == 0:
 722                  return False
 723              i = 3
 724              while i * i <= ni:
 725                  if ni % i == 0:
 726                      return False
 727                  i += 2
 728              return True
 729          if prop == "WHOLE":
 730              return n == int(n)
 731          if prop == "POSITIVE":
 732              return n > 0
 733          if prop == "NEGATIVE":
 734              return n < 0
 735          if prop == "DIVISIBLE_BY":
 736              divisor = await self._eval_input(block, "DIVISOR")
 737              try:
 738                  d = float(divisor)
 739              except (TypeError, ValueError):
 740                  return False
 741              if d == 0:
 742                  return False
 743              return n % d == 0
 744          return False
 745  
 746      async def _eval_math_round(self, block: dict):
 747          val = await self._eval_input(block, "NUM")
 748          op = block.get("fields", {}).get("OP", "ROUND")
 749          try:
 750              n = float(val)
 751          except (TypeError, ValueError):
 752              return 0
 753          if op == "ROUND":
 754              return round(n)
 755          if op == "ROUNDUP":
 756              return math.ceil(n)
 757          if op == "ROUNDDOWN":
 758              return math.floor(n)
 759          return 0
 760  
 761      async def _eval_math_on_list(self, block: dict):
 762          lst = await self._eval_input(block, "LIST")
 763          op = block.get("fields", {}).get("OP", "SUM")
 764          if not isinstance(lst, list) or not lst:
 765              return 0
 766          try:
 767              nums = [float(x) for x in lst]
 768          except (TypeError, ValueError):
 769              nums = []
 770          try:
 771              if op == "SUM":
 772                  return sum(nums)
 773              if op == "MIN":
 774                  return min(nums) if nums else 0
 775              if op == "MAX":
 776                  return max(nums) if nums else 0
 777              if op == "AVERAGE":
 778                  return statistics.mean(nums) if nums else 0
 779              if op == "MEDIAN":
 780                  return statistics.median(nums) if nums else 0
 781              if op == "MODE":
 782                  if not nums:
 783                      return []
 784                  # Blockly's "mode" returns a list of all most-frequent values.
 785                  try:
 786                      return statistics.multimode(nums)
 787                  except AttributeError:  # Python <3.8 fallback
 788                      return [statistics.mode(nums)]
 789              if op == "STD_DEV":
 790                  return statistics.pstdev(nums) if len(nums) > 0 else 0
 791              if op == "RANDOM":
 792                  return random.choice(lst)
 793          except statistics.StatisticsError:
 794              return 0
 795          return 0
 796  
 797      async def _eval_math_modulo(self, block: dict):
 798          a = await self._eval_input(block, "DIVIDEND")
 799          b = await self._eval_input(block, "DIVISOR")
 800          try:
 801              a, b = float(a), float(b)
 802          except (TypeError, ValueError):
 803              return 0
 804          if b == 0:
 805              return 0
 806          return a % b
 807  
 808      async def _eval_math_constrain(self, block: dict):
 809          val = await self._eval_input(block, "VALUE")
 810          lo = await self._eval_input(block, "LOW")
 811          hi = await self._eval_input(block, "HIGH")
 812          try:
 813              v, lo, hi = float(val), float(lo), float(hi)
 814          except (TypeError, ValueError):
 815              return 0
 816          return max(lo, min(hi, v))
 817  
 818      async def _eval_math_random_int(self, block: dict):
 819          lo = await self._eval_input(block, "FROM")
 820          hi = await self._eval_input(block, "TO")
 821          try:
 822              lo, hi = int(lo), int(hi)
 823          except (TypeError, ValueError):
 824              return 0
 825          if lo > hi:
 826              lo, hi = hi, lo
 827          return random.randint(lo, hi)
 828  
 829      def _eval_math_random_float(self, block: dict):
 830          return random.random()
 831  
 832      async def _eval_math_atan2(self, block: dict):
 833          x = await self._eval_input(block, "X")
 834          y = await self._eval_input(block, "Y")
 835          try:
 836              x, y = float(x), float(y)
 837          except (TypeError, ValueError):
 838              return 0
 839          return math.degrees(math.atan2(y, x))
 840  
 841      # ------------------------------------------------------------------
 842      # Logic helpers (existing + new)
 843      # ------------------------------------------------------------------
 844  
 845      async def _eval_logic_compare(self, block: dict):
 846          a = await self._eval_input(block, "A")
 847          b = await self._eval_input(block, "B")
 848          op = block.get("fields", {}).get("OP", "EQ")
 849          if op == "EQ":
 850              return a == b
 851          if op == "NEQ":
 852              return a != b
 853          try:
 854              a, b = float(a), float(b)
 855          except (TypeError, ValueError):
 856              return False
 857          if op == "LT":
 858              return a < b
 859          if op == "LTE":
 860              return a <= b
 861          if op == "GT":
 862              return a > b
 863          if op == "GTE":
 864              return a >= b
 865          return False
 866  
 867      async def _eval_logic_operation(self, block: dict):
 868          op = block.get("fields", {}).get("OP", "AND")
 869          a = await self._eval_input(block, "A")
 870          if op == "AND" and not a:
 871              return False
 872          if op == "OR" and a:
 873              return True
 874          b = await self._eval_input(block, "B")
 875          if op == "AND":
 876              return bool(a) and bool(b)
 877          if op == "OR":
 878              return bool(b)
 879          return False
 880  
 881      async def _eval_logic_negate(self, block: dict):
 882          val = await self._eval_input(block, "BOOL")
 883          return not val
 884  
 885      async def _eval_logic_ternary(self, block: dict):
 886          cond = await self._eval_input(block, "IF")
 887          if cond:
 888              return await self._eval_input(block, "THEN")
 889          return await self._eval_input(block, "ELSE")
 890  
 891      # ------------------------------------------------------------------
 892      # List helpers
 893      # ------------------------------------------------------------------
 894  
 895      async def _eval_lists_create_with(self, block: dict) -> list:
 896          count = block.get("extraState", {}).get("itemCount", 0)
 897          result = []
 898          for i in range(count):
 899              result.append(await self._eval_input(block, f"ADD{i}"))
 900          return result
 901  
 902      async def _eval_lists_repeat(self, block: dict) -> list:
 903          item = await self._eval_input(block, "ITEM")
 904          num = await self._eval_input(block, "NUM")
 905          try:
 906              n = int(num)
 907          except (TypeError, ValueError):
 908              n = 0
 909          return [item] * max(0, min(n, MAX_ITERATIONS))
 910  
 911      async def _eval_lists_length(self, block: dict) -> int:
 912          val = await self._eval_input(block, "VALUE")
 913          if isinstance(val, (list, str)):
 914              return len(val)
 915          return 0
 916  
 917      async def _eval_lists_isEmpty(self, block: dict) -> bool:
 918          val = await self._eval_input(block, "VALUE")
 919          if isinstance(val, (list, str)):
 920              return len(val) == 0
 921          return True
 922  
 923      async def _eval_lists_indexOf(self, block: dict) -> int:
 924          lst = await self._eval_input(block, "VALUE")
 925          find = await self._eval_input(block, "FIND")
 926          end = block.get("fields", {}).get("END", "FIRST")
 927          if not isinstance(lst, list):
 928              return 0
 929          try:
 930              if end == "FIRST":
 931                  return lst.index(find) + 1
 932              # LAST
 933              for i in range(len(lst) - 1, -1, -1):
 934                  if lst[i] == find:
 935                      return i + 1
 936              return 0
 937          except ValueError:
 938              return 0
 939  
 940      async def _eval_lists_getIndex(self, block: dict):
 941          mode = block.get("fields", {}).get("MODE", "GET")
 942          where = block.get("fields", {}).get("WHERE", "FROM_START")
 943          lst = await self._eval_input(block, "VALUE")
 944          if not isinstance(lst, list):
 945              return None
 946          at_val = None
 947          if where in ("FROM_START", "FROM_END"):
 948              at_val = await self._eval_input(block, "AT")
 949          idx = self._resolve_list_index(len(lst), where, at_val)
 950          if idx is None or not (0 <= idx < len(lst)):
 951              return None
 952          if mode == "GET":
 953              return lst[idx]
 954          if mode == "GET_REMOVE":
 955              return lst.pop(idx)
 956          if mode == "REMOVE":
 957              # Typically a statement but return None if used as value
 958              del lst[idx]
 959              return None
 960          return lst[idx]
 961  
 962      async def _eval_lists_getSublist(self, block: dict) -> list:
 963          lst = await self._eval_input(block, "LIST")
 964          if not isinstance(lst, list):
 965              return []
 966          where1 = block.get("fields", {}).get("WHERE1", "FROM_START")
 967          where2 = block.get("fields", {}).get("WHERE2", "FROM_START")
 968          at1 = None
 969          at2 = None
 970          if where1 in ("FROM_START", "FROM_END"):
 971              at1 = await self._eval_input(block, "AT1")
 972          if where2 in ("FROM_START", "FROM_END"):
 973              at2 = await self._eval_input(block, "AT2")
 974          start = self._resolve_sequence_slice(len(lst), where1, at1, end=False)
 975          end = self._resolve_sequence_slice(len(lst), where2, at2, end=True)
 976          if start is None or end is None or start > end:
 977              return []
 978          return list(lst[start:end])
 979  
 980      async def _eval_lists_split(self, block: dict):
 981          mode = block.get("fields", {}).get("MODE", "SPLIT")
 982          delim = await self._eval_input(block, "DELIM")
 983          delim = str(delim) if delim is not None else ""
 984          inp = await self._eval_input(block, "INPUT")
 985          if mode == "SPLIT":
 986              s = str(inp) if inp is not None else ""
 987              if not delim:
 988                  return list(s)
 989              return s.split(delim)
 990          # JOIN
 991          if not isinstance(inp, list):
 992              inp = [inp] if inp is not None else []
 993          return delim.join(str(x) for x in inp)
 994  
 995      async def _eval_lists_sort(self, block: dict) -> list:
 996          lst = await self._eval_input(block, "LIST")
 997          if not isinstance(lst, list):
 998              return []
 999          type_ = block.get("fields", {}).get("TYPE", "NUMERIC")
1000          direction = block.get("fields", {}).get("DIRECTION", "1")
1001          reverse = str(direction) == "-1"
1002  
1003          if type_ == "NUMERIC":
1004              def key(x):
1005                  try:
1006                      return float(x)
1007                  except (TypeError, ValueError):
1008                      return float("inf")
1009          elif type_ == "IGNORE_CASE":
1010              def key(x):
1011                  return str(x).lower()
1012          else:  # TEXT
1013              def key(x):
1014                  return str(x)
1015          try:
1016              return sorted(list(lst), key=key, reverse=reverse)
1017          except TypeError:
1018              return list(lst)
1019  
1020      async def _eval_lists_reverse(self, block: dict) -> list:
1021          lst = await self._eval_input(block, "LIST")
1022          if not isinstance(lst, list):
1023              return []
1024          return list(reversed(lst))
1025  
1026      # ------------------------------------------------------------------
1027      # Sequence index resolution (1-based Blockly semantics → 0-based Python)
1028      # ------------------------------------------------------------------
1029  
1030      def _resolve_list_index(self, length: int, where: str, at_val, for_insert: bool = False):
1031          """Return a 0-based Python index matching Blockly's semantics.
1032  
1033          - FROM_START: 1 → 0, 2 → 1, ...
1034          - FROM_END: 1 → length-1, 2 → length-2, ...
1035          - FIRST: 0
1036          - LAST: length-1 (for insert: length)
1037          - RANDOM: random valid index, or None on empty list
1038          """
1039          if where == "FIRST":
1040              return 0
1041          if where == "LAST":
1042              if for_insert:
1043                  return length
1044              return length - 1 if length > 0 else None
1045          if where == "RANDOM":
1046              if length == 0:
1047                  return None
1048              return random.randint(0, length - 1)
1049          try:
1050              at = int(at_val)
1051          except (TypeError, ValueError):
1052              return None
1053          if where == "FROM_START":
1054              return at - 1
1055          if where == "FROM_END":
1056              return length - at
1057          return None
1058  
1059      def _resolve_sequence_slice(self, length: int, where: str, at_val, end: bool):
1060          """Return a 0-based Python slice bound for Blockly's 1-based substring/sublist.
1061  
1062          Used by `text_getSubstring` and `lists_getSublist`. `end=True` means we
1063          want the exclusive upper bound (so a LAST/FROM_END position should be
1064          inclusive on the right side — we add 1).
1065          """
1066          if where == "FIRST":
1067              return 0 if not end else (1 if length > 0 else 0)
1068          if where == "LAST":
1069              return length
1070          try:
1071              at = int(at_val)
1072          except (TypeError, ValueError):
1073              return None
1074          if where == "FROM_START":
1075              # 1-based start (WHERE1) or inclusive end (WHERE2)
1076              return (at - 1) if not end else at
1077          if where == "FROM_END":
1078              return (length - at) if not end else (length - at + 1)
1079          return None
1080  
1081      # ------------------------------------------------------------------
1082      # Procedures
1083      # ------------------------------------------------------------------
1084  
1085      async def _invoke_procedure(self, call_block: dict, want_return: bool):
1086          extra = call_block.get("extraState", {}) or {}
1087          name = extra.get("name") or call_block.get("fields", {}).get("NAME", "")
1088          proc = self.procedures.get(name)
1089          if proc is None:
1090              logger.warning("Procedure '%s' not found", name)
1091              return None
1092  
1093          # Collect arg values: call block inputs are ARG0, ARG1, ... in the
1094          # order of the def's params.
1095          params = proc["params"]
1096          frame: dict = {}
1097          for i, p in enumerate(params):
1098              arg_val = await self._eval_input(call_block, f"ARG{i}")
1099              frame[p["id"]] = arg_val
1100  
1101          self._scope_stack.append(frame)
1102          try:
1103              # Execute body statements (STACK input on the def block).
1104              stack_input = proc["block"].get("inputs", {}).get("STACK")
1105              if stack_input and stack_input.get("block"):
1106                  try:
1107                      await self._exec_statement(stack_input["block"])
1108                  except _BlockReturn as e:
1109                      return e.value if want_return else None
1110  
1111              if want_return:
1112                  # Evaluate the RETURN input after a clean body fall-through.
1113                  return await self._eval_input(proc["block"], "RETURN")
1114              return None
1115          finally:
1116              self._scope_stack.pop()
1117  
1118      async def _eval_procedures_callreturn(self, block: dict):
1119          return await self._invoke_procedure(block, want_return=True)
1120  
1121      # ------------------------------------------------------------------
1122      # RESTai custom blocks
1123      # ------------------------------------------------------------------
1124  
1125      async def _eval_call_project(self, block: dict) -> str:
1126          project_name = block.get("fields", {}).get("PROJECT_NAME", "")
1127          text = await self._eval_input(block, "TEXT")
1128          if not project_name:
1129              return ""
1130  
1131          project_db = self.db.get_project_by_name(project_name)
1132          if project_db is None:
1133              logger.warning("Call Project: project '%s' not found", project_name)
1134              return ""
1135  
1136          project = self.brain.find_project(project_db.id, self.db)
1137          if project is None:
1138              logger.warning("Call Project: project '%s' could not be loaded", project_name)
1139              return ""
1140  
1141          # Propagate context to the sub-project's system prompt
1142          if self.context:
1143              project = project.with_context(self.context)
1144  
1145          from fastapi import BackgroundTasks
1146          background_tasks = BackgroundTasks()
1147  
1148          try:
1149              if self.chat_id:
1150                  from restai.models.models import ChatModel
1151                  from restai.helper import chat_main
1152                  q = ChatModel(question=str(text) if text else "", id=self.chat_id, image=self.image)
1153                  result = await chat_main(
1154                      self._fake_request, self.brain, project, q,
1155                      self.user, self.db, background_tasks,
1156                  )
1157              else:
1158                  from restai.models.models import QuestionModel
1159                  from restai.helper import question_main
1160                  q = QuestionModel(question=str(text) if text else "", image=self.image)
1161                  result = await question_main(
1162                      self._fake_request, self.brain, project, q,
1163                      self.user, self.db, background_tasks,
1164                  )
1165  
1166              # Execute queued background tasks (inference logging) since we're
1167              # not inside a FastAPI response lifecycle that would run them.
1168              for task in background_tasks.tasks:
1169                  try:
1170                      if inspect.iscoroutinefunction(task.func):
1171                          await task.func(*task.args, **task.kwargs)
1172                      else:
1173                          task.func(*task.args, **task.kwargs)
1174                  except Exception:
1175                      pass
1176  
1177              if isinstance(result, dict):
1178                  return result.get("answer", "")
1179              return str(result)
1180          except Exception as e:
1181              logger.exception("Call Project '%s' failed: %s", project_name, e)
1182              return ""
1183  
1184      async def _eval_classifier(self, block: dict) -> str:
1185          text = await self._eval_input(block, "TEXT")
1186          labels_raw = await self._eval_input(block, "LABELS")
1187          if not text or not labels_raw:
1188              return ""
1189  
1190          labels = [l.strip() for l in str(labels_raw).split(",") if l.strip()]
1191          if not labels:
1192              return ""
1193  
1194          model = block.get("fields", {}).get("MODEL")
1195  
1196          try:
1197              from restai.models.models import ClassifierModel
1198  
1199              classifier_input = ClassifierModel(sequence=str(text), labels=labels, model=model)
1200              result = self.brain.classify(classifier_input)
1201  
1202              if isinstance(result, dict) and result.get("labels"):
1203                  return result["labels"][0]
1204              if hasattr(result, "labels") and result.labels:
1205                  return result.labels[0]
1206              return str(result)
1207          except Exception as e:
1208              logger.exception("Classifier block failed: %s", e)
1209              return ""