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 ""